mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 13:59:47 +08:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d889c04084 | |||
| 5276fae6bd | |||
| c0941f8089 | |||
| 42b8b1fc99 | |||
| 53c788eb3b | |||
| c79066e96e | |||
| 05dba5c320 | |||
| 270a9af804 | |||
| 3bba18c1fe | |||
| 069c5dc7d7 | |||
| 00bf946403 | |||
| 1cb54420e0 | |||
| 6363bd83e2 | |||
| 7b8247544d | |||
| a9576ebc67 | |||
| 79ca378229 | |||
| e1a1fe23cb | |||
| 3d85a7e663 | |||
| 10aabb39f8 | |||
| 661c74cce4 | |||
| 82445f2a39 | |||
| df9ce95c46 | |||
| 88b433d565 | |||
| 5acef7151b | |||
| e4d4ec8334 | |||
| 4935dc8b1a | |||
| c43ed57ffc | |||
| d96368492e | |||
| cfc647fd38 | |||
| 5144335916 | |||
| fa82c3d910 | |||
| 90730bc551 | |||
| 154ccfe3ff | |||
| 93efa5aeb1 | |||
| 40ccbed74a | |||
| fbf2bc02c5 | |||
| a37c80e142 | |||
| 2e68aa64a4 | |||
| a702513487 | |||
| 059ebc759c | |||
| d4a5a34865 | |||
| b3b2f8ea36 | |||
| f19ed00cb2 | |||
| 8e58855047 | |||
| fdfb0e2dfe | |||
| b4e319ba8f | |||
| 691a9c2272 | |||
| 16db99a3ef | |||
| adb587439d | |||
| 6b2b28b421 | |||
| 0df63c3a0b | |||
| 7834d632e0 | |||
| ca9a0a1404 | |||
| 366e7b5403 | |||
| 709e026be1 | |||
| f6cef6bcbb | |||
| 4300e6899c | |||
| 4435366898 | |||
| 69cd89184e | |||
| 5771e6d8c5 | |||
| 970f5e741a | |||
| 8cea550627 | |||
| 82029160d1 | |||
| 46af919634 | |||
| 5560db595a | |||
| 3b031c7768 | |||
| 39c7e86a8d | |||
| f00ca11d9a | |||
| 87c2509268 | |||
| 20bf6d9ad8 | |||
| 33bc0a6255 | |||
| c15a44b439 | |||
| 6e533f938f | |||
| c60eb85629 | |||
| 20d2a78f87 | |||
| bb63ee2a2e | |||
| 5fc697166d | |||
| 1ed204bd5a | |||
| be2cbecfb0 | |||
| bce96e411d | |||
| fe404c493d | |||
| 6dd754ccdd | |||
| 1f4415c8ff | |||
| b25eb87bb4 | |||
| dbf35503a3 | |||
| 36ccbd0300 | |||
| bc878e5157 | |||
| 727375afa3 | |||
| f2c914887a | |||
| 9f76a96741 | |||
| 67e3e11cce | |||
| 5b4ba3ac3f | |||
| ce259e4547 | |||
| fff1a54b6b | |||
| 9437581c48 | |||
| bd4db203f8 | |||
| 51387498a5 | |||
| 6d1336d8bd | |||
| 42931d6294 | |||
| 4c9e57777b | |||
| 210905ef66 | |||
| bd4de7b995 | |||
| 832f06e86e | |||
| a7ba3d73db | |||
| e2a15aadcc | |||
| e3f00c103e | |||
| a501202ab6 | |||
| 166cb323dd | |||
| b7585ebf71 | |||
| 3205239067 | |||
| c5b3066962 | |||
| 84d7777246 | |||
| 8412392788 | |||
| 7cc2e640b0 | |||
| e2fa3065f7 | |||
| 8787cbe85b | |||
| 226d501e9e | |||
| 6796ec67bd | |||
| 856e8e5205 | |||
| a7d6b5c69b | |||
| 6e18725906 | |||
| 7133d54f04 | |||
| 208bb20e98 | |||
| 6edc1b7822 | |||
| e754eae5ae | |||
| cc016f3b16 |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.40.0"
|
||||
"version": "1.61.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
@@ -18,6 +18,7 @@
|
||||
"./skills/baoyu-xhs-images",
|
||||
"./skills/baoyu-post-to-x",
|
||||
"./skills/baoyu-post-to-wechat",
|
||||
"./skills/baoyu-post-to-weibo",
|
||||
"./skills/baoyu-article-illustrator",
|
||||
"./skills/baoyu-cover-image",
|
||||
"./skills/baoyu-slide-deck",
|
||||
@@ -45,7 +46,8 @@
|
||||
"./skills/baoyu-compress-image",
|
||||
"./skills/baoyu-url-to-markdown",
|
||||
"./skills/baoyu-format-markdown",
|
||||
"./skills/baoyu-markdown-to-html"
|
||||
"./skills/baoyu-markdown-to-html",
|
||||
"./skills/baoyu-translate"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -35,6 +35,7 @@ Just run `/release-skills` - auto-detects your project configuration.
|
||||
### Step 1: Detect Project Configuration
|
||||
|
||||
1. Check for `.releaserc.yml` (optional config override)
|
||||
- If present, inspect whether it defines release hooks
|
||||
2. Auto-detect version file by scanning (priority order):
|
||||
- `package.json` (Node.js)
|
||||
- `pyproject.toml` (Python)
|
||||
@@ -48,18 +49,46 @@ Just run `/release-skills` - auto-detects your project configuration.
|
||||
4. Identify language of each changelog by filename suffix
|
||||
5. Display detected configuration
|
||||
|
||||
**Project Hook Contract**:
|
||||
|
||||
If `.releaserc.yml` defines `release.hooks`, keep the release workflow generic and delegate project-specific packaging/publishing to those hooks.
|
||||
|
||||
Supported hooks:
|
||||
|
||||
| Hook | Purpose | Expected Responsibility |
|
||||
|------|---------|-------------------------|
|
||||
| `prepare_artifact` | Make one target releasable | Validate the target is self-contained, sync/embed local dependencies, optionally stage extra files |
|
||||
| `publish_artifact` | Publish one releasable target | Upload the prepared target (or a staged directory if the project uses one), attach version/changelog/tags |
|
||||
|
||||
Supported placeholders:
|
||||
|
||||
| Placeholder | Meaning |
|
||||
|-------------|---------|
|
||||
| `{project_root}` | Absolute path to repository root |
|
||||
| `{target}` | Absolute path to the module/skill being released |
|
||||
| `{artifact_dir}` | Absolute path to a temporary staging directory for this target, when the project uses one |
|
||||
| `{version}` | Version selected by the release workflow |
|
||||
| `{dry_run}` | `true` or `false` |
|
||||
| `{release_notes_file}` | Absolute path to a UTF-8 file containing release notes/changelog text |
|
||||
|
||||
Execution rules:
|
||||
- Keep the skill generic: do not hardcode registry/package-manager/project layout details into this SKILL.
|
||||
- If `prepare_artifact` exists, run it once per target before publish-related checks that need the final releasable target state.
|
||||
- Write release notes to a temp file and pass that file path to `publish_artifact`; do not inline multiline changelog text into shell commands.
|
||||
- If hooks are absent, fall back to the default project-agnostic release workflow.
|
||||
|
||||
**Language Detection Rules**:
|
||||
|
||||
| Filename Pattern | Language |
|
||||
|------------------|----------|
|
||||
| `CHANGELOG.md` (no suffix) | en (default) |
|
||||
| `CHANGELOG.zh.md` / `CHANGELOG_CN.md` / `CHANGELOG.zh-CN.md` | zh |
|
||||
| `CHANGELOG.ja.md` / `CHANGELOG_JP.md` | ja |
|
||||
| `CHANGELOG.ko.md` / `CHANGELOG_KR.md` | ko |
|
||||
| `CHANGELOG.de.md` / `CHANGELOG_DE.md` | de |
|
||||
| `CHANGELOG.fr.md` / `CHANGELOG_FR.md` | fr |
|
||||
| `CHANGELOG.es.md` / `CHANGELOG_ES.md` | es |
|
||||
| `CHANGELOG.{lang}.md` | Corresponding language code |
|
||||
Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code.
|
||||
|
||||
| Pattern | Example | Language |
|
||||
|---------|---------|----------|
|
||||
| No suffix | `CHANGELOG.md` | en (default) |
|
||||
| `_{LANG}` (uppercase) | `CHANGELOG_CN.md`, `CHANGELOG_JP.md` | Corresponding language |
|
||||
| `.{lang}` (lowercase) | `CHANGELOG.zh.md`, `CHANGELOG.ja.md` | Corresponding language |
|
||||
| `.{lang-region}` | `CHANGELOG.zh-CN.md` | Corresponding region variant |
|
||||
|
||||
Common language codes: `zh` (Chinese), `ja` (Japanese), `ko` (Korean), `de` (German), `fr` (French), `es` (Spanish).
|
||||
|
||||
**Output Example**:
|
||||
```
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
node scripts/sync-shared-skill-packages.mjs --repo-root "$REPO_ROOT" --enforce-clean
|
||||
@@ -152,6 +152,8 @@ slide-deck/
|
||||
infographic/
|
||||
illustrations/
|
||||
comic/
|
||||
translate/
|
||||
posts/
|
||||
### IntelliJ IDEA ###
|
||||
.idea
|
||||
*.iws
|
||||
@@ -159,3 +161,8 @@ comic/
|
||||
*.ipr
|
||||
|
||||
.claude/skills/baoyu-skill-evolution
|
||||
# ClawHub local state (current and legacy directory names from the official CLI)
|
||||
.clawhub/
|
||||
.clawdhub/
|
||||
.release-artifacts/
|
||||
.worktrees/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
release:
|
||||
target_globs:
|
||||
- skills/*
|
||||
hooks:
|
||||
prepare_artifact: node scripts/sync-shared-skill-packages.mjs --repo-root "{project_root}" --target "{target}"
|
||||
publish_artifact: node scripts/publish-skill.mjs --skill-dir "{target}" --version "{version}" --changelog-file "{release_notes_file}" --dry-run "{dry_run}"
|
||||
+271
@@ -2,6 +2,277 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.61.0 - 2026-03-11
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: add multi-account support with `--account` CLI arg, EXTEND.md accounts block, isolated Chrome profiles, and credential resolution chain
|
||||
|
||||
### Fixes
|
||||
- Exclude `out/dist/build` dirs and `bun.lockb` from skill release files
|
||||
- Use proper MIME types in skill publish to fix ClawhHub rejection
|
||||
|
||||
## 1.60.0 - 2026-03-11
|
||||
|
||||
### Features
|
||||
- `baoyu-url-to-markdown`: support reusing existing Chrome CDP instances and fix port detection order
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: add missing `fs` import in x-article
|
||||
|
||||
### Refactor
|
||||
- Unify all CDP skills to use shared `baoyu-chrome-cdp` package with vendored copies
|
||||
- Simplify CLAUDE.md, move detailed documentation to `docs/` directory
|
||||
- Publish skills directly from synced vendor, removing separate artifact preparation step
|
||||
|
||||
## 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
|
||||
|
||||
### Features
|
||||
- Add ClawHub/OpenClaw publishing support with sync script and README documentation
|
||||
|
||||
### Refactor
|
||||
- Add openclaw metadata to all skill frontmatter for ClawHub registry compatibility
|
||||
- Rename `SKILL_DIR` to `baseDir` across all skills for consistency
|
||||
- `baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`: dynamic script path in usage display
|
||||
- `baoyu-comic`, `baoyu-xhs-images`: use skill interface instead of direct script invocation for image generation
|
||||
|
||||
## 1.56.1 - 2026-03-08
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-weibo`: simplify article image insertion with Backspace-based placeholder deletion for ProseMirror compatibility
|
||||
|
||||
## 1.56.0 - 2026-03-08
|
||||
|
||||
### Features
|
||||
- `baoyu-article-illustrator`: preset-first selection flow with categorized style presets by content type
|
||||
- `baoyu-xhs-images`: streamline workflow from 6 to 4 steps with Smart Confirm (Quick/Customize/Detailed paths)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: improve image upload reliability with file chooser interception and fallback
|
||||
|
||||
## 1.55.0 - 2026-03-08
|
||||
|
||||
### Features
|
||||
- `baoyu-article-illustrator`: add screen-print style and `--preset` flag for quick type + style selection
|
||||
- `baoyu-cover-image`: add screen-print rendering and duotone palette with 5 new style presets
|
||||
- `baoyu-xhs-images`: add screen-print style and `--preset` flag with 23 built-in presets
|
||||
|
||||
### Documentation
|
||||
- Add credits section to both READMEs acknowledging open source inspirations
|
||||
|
||||
## 1.54.1 - 2026-03-07
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-x`: keep composed posts open in Chrome so users can review and publish manually
|
||||
|
||||
### Documentation
|
||||
- `baoyu-post-to-x`: document default post type selection and manual publishing flow
|
||||
- `README`: add Star History charts to the English and Chinese READMEs
|
||||
|
||||
## 1.54.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-format-markdown`: improve title and summary generation with style-differentiated candidates, prohibited patterns, and hook-first principles
|
||||
- `baoyu-markdown-to-html`: add `--cite` option to convert ordinary external links to numbered bottom citations
|
||||
- `baoyu-post-to-wechat`: enable bottom citations by default for markdown input, add `--no-cite` flag to disable
|
||||
- `baoyu-translate`: support external glossary files via `glossary_files` in EXTEND.md (markdown table or YAML)
|
||||
- `baoyu-translate`: add frontmatter transformation rules to rename source metadata fields with `source` prefix
|
||||
|
||||
## 1.53.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-url-to-markdown`: save rendered HTML snapshot as `-captured.html` alongside markdown output
|
||||
- `baoyu-url-to-markdown`: Defuddle-first markdown conversion with automatic fallback to legacy Readability/selector extractor
|
||||
|
||||
## 1.52.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-weibo`: add video upload support via `--video` flag (max 18 files total)
|
||||
- `baoyu-post-to-weibo`: switch from clipboard paste to `DOM.setFileInputFiles` for more reliable uploads
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-weibo`: add Chrome health check with auto-restart for unresponsive instances
|
||||
- `baoyu-post-to-weibo`: add navigation check to ensure Weibo home page before posting
|
||||
|
||||
## 1.51.2 - 2026-03-06
|
||||
|
||||
### Fixes
|
||||
- `release-skills`: replace explicit language filename patterns (e.g. `CHANGELOG.de.md`) with generic pattern to avoid Gen Agent Trust Hub URL scanner false positive
|
||||
- `baoyu-infographic`: add credential/secret stripping instructions to address Snyk W007 insecure credential handling audit
|
||||
|
||||
## 1.51.1 - 2026-03-06
|
||||
|
||||
### Refactor
|
||||
- Unify Chrome CDP profile path — all skills now share `baoyu-skills/chrome-profile` instead of per-skill directories
|
||||
- Fix `baoyu-post-to-weibo` incorrectly reusing `x-browser-profile` path
|
||||
|
||||
### Fixes
|
||||
- Remove `curl | bash` remote code execution pattern from all install instructions
|
||||
- Enforce HTTPS-only for remote image downloads in `md-to-html` scripts
|
||||
- Add redirect limit (max 5) to prevent infinite redirect loops
|
||||
- Add Security Guidelines section to CLAUDE.md
|
||||
|
||||
## 1.51.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-weibo`: new skill for posting to Weibo — supports text posts with images and headline articles (头条文章) via Chrome CDP
|
||||
- `baoyu-format-markdown`: add title/summary multi-candidate selection — generates 3 candidates for user to pick, with `auto_select` EXTEND.md support
|
||||
|
||||
## 1.50.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-translate`: expand translation style presets from 4 to 9 — add academic, business, humorous, conversational, and elegant styles
|
||||
- `baoyu-translate`: add `--style` CLI flag for per-invocation style override
|
||||
- `baoyu-translate`: integrate style instructions into subagent prompt template
|
||||
|
||||
## 1.49.0 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-format-markdown`: add reader-perspective content analysis phase — analyzes highlights, structure, and formatting issues before applying formatting
|
||||
- `baoyu-format-markdown`: restructure workflow from 8 steps to 7 with explicit do/don't formatting principles and completion report
|
||||
- `baoyu-translate`: extract Step 2 workflow mechanics to separate reference file for cleaner SKILL.md
|
||||
- `baoyu-translate`: expand trigger keywords (改成中文, 快翻, 本地化, etc.) for better skill activation
|
||||
- `baoyu-translate`: add proactive warning for long content in quick mode
|
||||
- `baoyu-translate`: save frontmatter to `chunks/frontmatter.md` during chunking
|
||||
|
||||
## 1.48.2 - 2026-03-06
|
||||
|
||||
### Features
|
||||
- `baoyu-translate`: add figurative language & emotional fidelity review steps to refined workflow critique and revision stages
|
||||
- `baoyu-translate`: enhance quick mode to enforce meaning-first translation principles for figurative language
|
||||
|
||||
## 1.48.1 - 2026-03-05
|
||||
|
||||
### Features
|
||||
- `baoyu-translate`: add figurative language & metaphor mapping to analysis step — interprets metaphors, idioms, and implied meanings before translation instead of translating literally
|
||||
- `baoyu-translate`: add "meaning over words", "figurative language", and "emotional fidelity" translation principles to SKILL.md, refined workflow, and subagent prompt template
|
||||
|
||||
## 1.48.0 - 2026-03-05
|
||||
|
||||
### Features
|
||||
- `baoyu-translate`: add `--output-dir` option to chunk.ts — chunks now write to the translation output directory instead of the source file directory
|
||||
- `baoyu-translate`: improve refined workflow — split Review into Critical Review + Revision (5→6 steps), add Europeanized language diagnosis for CJK targets
|
||||
|
||||
## 1.47.0 - 2026-03-05
|
||||
|
||||
### Features
|
||||
- Add `baoyu-translate` skill — three-mode translation (quick/normal/refined) with custom glossaries, audience-aware translation, and parallel chunked translation for long documents
|
||||
- Add cross-platform PowerShell support for EXTEND.md preference checks across all skills
|
||||
|
||||
## 1.46.0 - 2026-03-05
|
||||
|
||||
### Features
|
||||
- Add `--output-dir` option to url-to-markdown for custom output directory with auto-generated filenames
|
||||
|
||||
## 1.45.1 - 2026-03-05
|
||||
|
||||
### Refactor
|
||||
- Replace hardcoded `npx -y bun` with `${BUN_X}` runtime variable across all skills — prefers native `bun`, falls back to `npx -y bun`
|
||||
- Add Runtime Detection section to CLAUDE.md and Script Directory instructions in all SKILL.md files
|
||||
|
||||
## 1.45.0 - 2026-03-05
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: add post-composition verification for X Articles — automatically checks remaining placeholders and image count after all images are inserted
|
||||
- `baoyu-post-to-x`: increase CDP timeout to 60s and add 3s DOM stabilization delay between image insertions for long articles
|
||||
|
||||
## 1.44.0 - 2026-03-05
|
||||
|
||||
### Features
|
||||
- `baoyu-url-to-markdown`: add `--download-media` flag to download images and videos to local directories, rewriting markdown links to local paths
|
||||
- `baoyu-url-to-markdown`: extract cover image from page meta (og:image) into YAML front matter `coverImage` field
|
||||
- `baoyu-url-to-markdown`: handle `data-src` lazy loading for WeChat and similar sites
|
||||
- `baoyu-url-to-markdown`: add EXTEND.md preferences with first-time setup for media download behavior
|
||||
|
||||
## 1.43.2 - 2026-03-05
|
||||
|
||||
### Refactor
|
||||
- `baoyu-url-to-markdown`: replace custom HTML extraction (linkedom + Readability + Turndown) with defuddle library for cleaner content extraction and markdown conversion
|
||||
|
||||
## 1.43.1 - 2026-03-02
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-x`: auto-detect WSL environment and resolve Chrome profile to Windows-native path for stable login persistence
|
||||
- `baoyu-post-to-wechat`: auto-detect WSL environment and resolve Chrome profile to Windows-native path for stable login persistence
|
||||
- `baoyu-danger-gemini-web`: WSL auto-detection for Chrome profile path; add `GEMINI_WEB_DEBUG_PORT` env var for fixed debug port
|
||||
- `baoyu-danger-x-to-markdown`: WSL auto-detection for Chrome profile path; add `X_DEBUG_PORT` env var for fixed debug port
|
||||
|
||||
## 1.43.0 - 2026-03-02
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: support env var overrides for browser debug port (`WECHAT_BROWSER_DEBUG_PORT`) and profile directory (`WECHAT_BROWSER_PROFILE_DIR`)
|
||||
- `baoyu-post-to-x`: support env var overrides for browser debug port (`X_BROWSER_DEBUG_PORT`) and profile directory (`X_BROWSER_PROFILE_DIR`)
|
||||
|
||||
## 1.42.3 - 2026-03-02
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: use standard size presets for DashScope aspect ratio mapping instead of free-form calculation
|
||||
|
||||
## 1.42.2 - 2026-03-01
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: inline rendering pipeline (no subprocess), fix CJK emphasis order, enhance modern theme with GFM alerts and improved typography
|
||||
- `baoyu-post-to-wechat`: internalize markdown conversion with modular renderer, add color support, simplify publishing workflow
|
||||
|
||||
## 1.42.1 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: modularize render.ts into cli, constants, extend-config, html-builder, renderer, themes, and types modules; bundle code highlighting themes locally
|
||||
|
||||
## 1.42.0 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: consolidate heritage and warm into single modern theme, add per-theme color defaults (default→blue, grace→purple, simple→green, modern→orange)
|
||||
- `baoyu-post-to-wechat`: add default color preference support in EXTEND.md, add modern theme option to first-time setup
|
||||
|
||||
## 1.41.0 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-markdown-to-html`: rename themes (red→heritage, orange→warm), add 13 named color presets, serif-cjk font family, and per-theme style defaults
|
||||
|
||||
## 1.40.1 - 2026-02-28
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: clarify model resolution priority (EXTEND.md overrides env vars) and display current model with switch hints during generation
|
||||
|
||||
## 1.40.0 - 2026-02-28
|
||||
|
||||
### Features
|
||||
|
||||
+271
@@ -2,6 +2,277 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.61.0 - 2026-03-11
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:新增多账号支持,通过 `--account` 参数选择账号,EXTEND.md 支持 accounts 配置块,每个账号独立 Chrome 配置目录和凭证解析链
|
||||
|
||||
### 修复
|
||||
- 排除 `out/dist/build` 目录和 `bun.lockb` 文件,避免打包到技能发布文件中
|
||||
- 修复技能发布时 MIME 类型不正确导致 ClawhHub 拒绝的问题
|
||||
|
||||
## 1.60.0 - 2026-03-11
|
||||
|
||||
### 新功能
|
||||
- `baoyu-url-to-markdown`:支持复用已有 Chrome CDP 实例,修复端口检测顺序问题
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:补充 x-article 缺失的 `fs` 导入
|
||||
|
||||
### 重构
|
||||
- 统一所有 CDP 技能使用共享 `baoyu-chrome-cdp` 包,各技能内置 vendor 副本
|
||||
- 精简 CLAUDE.md,将详细文档移至 `docs/` 目录
|
||||
- 从 synced vendor 直接发布技能,移除单独的 artifact 准备步骤
|
||||
|
||||
## 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
|
||||
|
||||
### 新功能
|
||||
- 新增 ClawHub/OpenClaw 发布支持,包含同步脚本和 README 文档
|
||||
|
||||
### 重构
|
||||
- 为所有 skill 前言添加 openclaw 元数据,兼容 ClawHub 注册表
|
||||
- 全部 skill 中将 `SKILL_DIR` 统一重命名为 `baseDir`
|
||||
- `baoyu-danger-gemini-web`、`baoyu-danger-x-to-markdown`:使用动态脚本路径显示用法
|
||||
- `baoyu-comic`、`baoyu-xhs-images`:通过 skill 接口调用图片生成,不再直接调用脚本
|
||||
|
||||
## 1.56.1 - 2026-03-08
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-weibo`:简化头条文章图片插入逻辑,使用 Backspace 按键替代复杂的 deleteContents 方案,兼容 ProseMirror 编辑器
|
||||
|
||||
## 1.56.0 - 2026-03-08
|
||||
|
||||
### 新功能
|
||||
- `baoyu-article-illustrator`:预设优先选择流程,按内容类型分类的风格预设
|
||||
- `baoyu-xhs-images`:精简工作流从 6 步到 4 步,新增智能确认(快速/自定义/详细三种路径)
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:通过文件选择器拦截改进图片上传可靠性
|
||||
|
||||
## 1.55.0 - 2026-03-08
|
||||
|
||||
### 新功能
|
||||
- `baoyu-article-illustrator`:新增 screen-print 风格和 `--preset` 快捷预设(如 tech-explainer、opinion-piece)
|
||||
- `baoyu-cover-image`:新增 screen-print 渲染风格和 duotone 调色板,包含 5 个新预设(poster-art、mondo 等)
|
||||
- `baoyu-xhs-images`:新增 screen-print 风格和 `--preset` 快捷预设,内置 23 个场景预设
|
||||
|
||||
### 文档
|
||||
- 为中英文 README 新增致谢章节,致敬相关开源项目
|
||||
|
||||
## 1.54.1 - 2026-03-07
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-x`:保持已填充的发帖窗口处于打开状态,方便用户手动检查并发布
|
||||
|
||||
### 文档
|
||||
- `baoyu-post-to-x`:补充默认帖子类型选择规则和手动发布流程说明
|
||||
- `README`:为中英文 README 新增 Star History 图表
|
||||
|
||||
## 1.54.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-format-markdown`:优化标题和摘要生成,支持多风格候选(颠覆型、方案型、悬念型、数字型),新增禁用模式和钩子优先原则
|
||||
- `baoyu-markdown-to-html`:新增 `--cite` 选项,将普通外链转换为底部编号引用
|
||||
- `baoyu-post-to-wechat`:Markdown 输入默认启用底部引用,新增 `--no-cite` 标志可关闭
|
||||
- `baoyu-translate`:EXTEND.md 支持 `glossary_files` 加载外部术语表文件(Markdown 表格或 YAML 格式)
|
||||
- `baoyu-translate`:新增 frontmatter 转换规则,翻译时将源文章元数据字段添加 `source` 前缀
|
||||
|
||||
## 1.53.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-url-to-markdown`:将渲染后的 HTML 快照保存为 `-captured.html`,与 Markdown 文件并列输出
|
||||
- `baoyu-url-to-markdown`:优先使用 Defuddle 转换,失败时自动回退到旧版 Readability/选择器提取器
|
||||
|
||||
## 1.52.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-weibo`:新增 `--video` 视频上传支持(图片+视频最多 18 个文件)
|
||||
- `baoyu-post-to-weibo`:上传方式从剪贴板粘贴改为 `DOM.setFileInputFiles`,提升上传可靠性
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-weibo`:新增 Chrome 健康检查,无响应时自动重启
|
||||
- `baoyu-post-to-weibo`:发布前检查页面是否在微博首页,避免在错误页面操作
|
||||
|
||||
## 1.51.2 - 2026-03-06
|
||||
|
||||
### 修复
|
||||
- `release-skills`:将显式语言文件名模式(如 `CHANGELOG.de.md`)替换为通用模式,避免 Gen Agent Trust Hub URL 扫描器误报
|
||||
- `baoyu-infographic`:新增凭证/密钥剥离指令,解决 Snyk W007 不安全凭证处理审计问题
|
||||
|
||||
## 1.51.1 - 2026-03-06
|
||||
|
||||
### 重构
|
||||
- 统一 Chrome CDP profile 路径——所有 skill 共享 `baoyu-skills/chrome-profile`,不再各自独立目录
|
||||
- 修复 `baoyu-post-to-weibo` 错误复用 `x-browser-profile` 路径的问题
|
||||
|
||||
### 修复
|
||||
- 移除所有安装说明中的 `curl | bash` 远程代码执行模式
|
||||
- `md-to-html` 脚本强制仅允许 HTTPS 下载远程图片
|
||||
- 添加重定向次数限制(最多 5 次),防止无限重定向
|
||||
- 在 CLAUDE.md 中新增安全准则章节
|
||||
|
||||
## 1.51.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-weibo`:新增微博发布技能——支持带图文本发布和头条文章,通过 Chrome CDP 自动化操作
|
||||
- `baoyu-format-markdown`:新增标题/摘要多候选项选择——生成 3 个候选供用户选择,支持 EXTEND.md 中的 `auto_select` 配置
|
||||
|
||||
## 1.50.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-translate`:翻译风格预设从 4 种扩展到 9 种——新增学术、商务、幽默、口语化和优雅风格
|
||||
- `baoyu-translate`:新增 `--style` 命令行参数,支持按次指定翻译风格
|
||||
- `baoyu-translate`:将风格指令集成到子代理提示词模板
|
||||
|
||||
## 1.49.0 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-format-markdown`:新增读者视角内容分析阶段——在应用格式之前先分析要点、结构和格式问题
|
||||
- `baoyu-format-markdown`:重构工作流从 8 步精简为 7 步,新增明确的格式化原则和完成报告模板
|
||||
- `baoyu-translate`:将步骤 2 的工作流机制提取到独立参考文件,精简 SKILL.md
|
||||
- `baoyu-translate`:扩展触发关键词(改成中文、快翻、本地化等),提升技能激活准确度
|
||||
- `baoyu-translate`:快速翻译模式下对长内容主动提示切换建议
|
||||
- `baoyu-translate`:分块时将 frontmatter 保存到 `chunks/frontmatter.md`
|
||||
|
||||
## 1.48.2 - 2026-03-06
|
||||
|
||||
### 新功能
|
||||
- `baoyu-translate`:在精翻工作流的审查和修订阶段新增比喻语言与情感忠实度检查
|
||||
- `baoyu-translate`:增强快速翻译模式,强制执行比喻语言的意义优先翻译原则
|
||||
|
||||
## 1.48.1 - 2026-03-05
|
||||
|
||||
### 新功能
|
||||
- `baoyu-translate`:在分析阶段新增比喻语言与隐喻映射——翻译前先解读隐喻、习语和隐含意义,避免字面直译
|
||||
- `baoyu-translate`:新增"意义优先于字面"、"比喻语言解读"、"情感忠实度"三项翻译原则,同步更新 SKILL.md、精翻工作流和子代理提示词模板
|
||||
|
||||
## 1.48.0 - 2026-03-05
|
||||
|
||||
### 新功能
|
||||
- `baoyu-translate`:为 chunk.ts 新增 `--output-dir` 选项——分块文件现在写入翻译输出目录而非源文件目录
|
||||
- `baoyu-translate`:优化精翻工作流——将审校拆分为批判性审查 + 修订(5→6 步),新增中日韩目标语言的欧化表达诊断
|
||||
|
||||
## 1.47.0 - 2026-03-05
|
||||
|
||||
### 新功能
|
||||
- 新增 `baoyu-translate` 翻译技能——支持快速/标准/精翻三种模式,自定义术语表、面向受众翻译、长文档自动分块并行翻译
|
||||
- 为所有技能的 EXTEND.md 偏好检测添加 PowerShell 跨平台支持
|
||||
|
||||
## 1.46.0 - 2026-03-05
|
||||
|
||||
### 新功能
|
||||
- 为 url-to-markdown 新增 `--output-dir` 选项,支持自定义输出目录并自动生成文件名
|
||||
|
||||
## 1.45.1 - 2026-03-05
|
||||
|
||||
### 重构
|
||||
- 将所有技能中硬编码的 `npx -y bun` 替换为 `${BUN_X}` 运行时变量——优先使用原生 `bun`,回退到 `npx -y bun`
|
||||
- 在 CLAUDE.md 中新增运行时检测章节,在所有 SKILL.md 的脚本目录说明中添加运行时解析步骤
|
||||
|
||||
## 1.45.0 - 2026-03-05
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:X 文章发布后自动验证——检查残留占位符和图片数量是否正确
|
||||
- `baoyu-post-to-x`:增加 CDP 超时至 60 秒,图片插入间隔增加 3 秒 DOM 稳定等待,改善长文章发布稳定性
|
||||
|
||||
## 1.44.0 - 2026-03-05
|
||||
|
||||
### 新功能
|
||||
- `baoyu-url-to-markdown`:新增 `--download-media` 参数,支持下载图片和视频到本地目录,并将 Markdown 中的链接改写为本地路径
|
||||
- `baoyu-url-to-markdown`:从页面 meta 信息(og:image)提取封面图,写入 YAML front matter 的 `coverImage` 字段
|
||||
- `baoyu-url-to-markdown`:支持 `data-src` 懒加载图片提取(兼容微信公众号等站点)
|
||||
- `baoyu-url-to-markdown`:新增 EXTEND.md 偏好设置,支持首次使用引导配置媒体下载行为
|
||||
|
||||
## 1.43.2 - 2026-03-05
|
||||
|
||||
### 重构
|
||||
- `baoyu-url-to-markdown`:使用 defuddle 库替换自定义 HTML 提取逻辑(linkedom + Readability + Turndown),简化内容提取和 Markdown 转换
|
||||
|
||||
## 1.43.1 - 2026-03-02
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-x`:自动检测 WSL 环境,将 Chrome profile 路径解析为 Windows 本地路径,解决登录态丢失问题
|
||||
- `baoyu-post-to-wechat`:自动检测 WSL 环境,将 Chrome profile 路径解析为 Windows 本地路径,解决登录态丢失问题
|
||||
- `baoyu-danger-gemini-web`:WSL 自动检测 Chrome profile 路径;新增 `GEMINI_WEB_DEBUG_PORT` 环境变量支持固定调试端口
|
||||
- `baoyu-danger-x-to-markdown`:WSL 自动检测 Chrome profile 路径;新增 `X_DEBUG_PORT` 环境变量支持固定调试端口
|
||||
|
||||
## 1.43.0 - 2026-03-02
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:支持通过环境变量覆盖浏览器调试端口(`WECHAT_BROWSER_DEBUG_PORT`)和配置目录(`WECHAT_BROWSER_PROFILE_DIR`)
|
||||
- `baoyu-post-to-x`:支持通过环境变量覆盖浏览器调试端口(`X_BROWSER_DEBUG_PORT`)和配置目录(`X_BROWSER_PROFILE_DIR`)
|
||||
|
||||
## 1.42.3 - 2026-03-02
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:DashScope 宽高比映射改用标准预设尺寸匹配,避免自由计算产生无效分辨率
|
||||
|
||||
## 1.42.2 - 2026-03-01
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:内联渲染管线(移除子进程),修复 CJK 强调符号处理顺序,增强 modern 主题(GFM 警告块、排版改进)
|
||||
- `baoyu-post-to-wechat`:内置 Markdown 转换模块化渲染器,新增颜色支持,简化发布流程
|
||||
|
||||
## 1.42.1 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:将 render.ts 拆分为 cli、constants、extend-config、html-builder、renderer、themes、types 模块;本地打包代码高亮主题
|
||||
|
||||
## 1.42.0 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:合并 heritage 和 warm 为 modern 主题,新增主题默认颜色(default→蓝、grace→紫、simple→绿、modern→橙)
|
||||
- `baoyu-post-to-wechat`:EXTEND.md 新增默认颜色配置,首次设置增加 modern 主题和颜色选择
|
||||
|
||||
## 1.41.0 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-markdown-to-html`:重命名主题(red→heritage、orange→warm),新增 13 个颜色预设、serif-cjk 字体、主题级样式默认值
|
||||
|
||||
## 1.40.1 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:明确模型解析优先级(EXTEND.md 优先于环境变量),生成图片时显示当前模型及切换方式
|
||||
|
||||
## 1.40.0 - 2026-02-28
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,430 +1,76 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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. Version: **1.61.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
Skills are organized into three plugin categories in `marketplace.json`:
|
||||
Skills organized into three categories in `.claude-plugin/marketplace.json` (defines plugin metadata, version, and skill paths):
|
||||
|
||||
```
|
||||
skills/
|
||||
├── [content-skills] # Content generation and publishing
|
||||
│ ├── baoyu-xhs-images/ # Xiaohongshu infographic series (1-10 images)
|
||||
│ ├── baoyu-cover-image/ # Article cover images (2.35:1 aspect)
|
||||
│ ├── baoyu-slide-deck/ # Presentation slides with outlines
|
||||
│ ├── baoyu-article-illustrator/ # Smart illustration placement
|
||||
│ ├── baoyu-comic/ # Knowledge comics (Logicomix/Ohmsha style)
|
||||
│ ├── baoyu-post-to-x/ # X/Twitter posting automation
|
||||
│ └── baoyu-post-to-wechat/ # WeChat Official Account posting
|
||||
│
|
||||
├── [ai-generation-skills] # AI-powered generation backends
|
||||
│ └── baoyu-danger-gemini-web/ # Gemini API wrapper (text + image gen)
|
||||
│
|
||||
└── [utility-skills] # Utility tools for content processing
|
||||
├── baoyu-danger-x-to-markdown/ # X/Twitter content to markdown
|
||||
└── baoyu-compress-image/ # Image compression
|
||||
```
|
||||
|
||||
**Plugin Categories**:
|
||||
| Category | Description |
|
||||
|----------|-------------|
|
||||
| `content-skills` | Skills that generate or publish content (images, slides, comics, posts) |
|
||||
| `ai-generation-skills` | Backend skills providing AI generation capabilities |
|
||||
| `utility-skills` | Helper tools for content processing (conversion, compression) |
|
||||
| `content-skills` | Generate or publish content (images, slides, comics, posts) |
|
||||
| `ai-generation-skills` | AI generation backends |
|
||||
| `utility-skills` | Content processing (conversion, compression, translation) |
|
||||
|
||||
Each skill contains:
|
||||
- `SKILL.md` - YAML front matter (name, description) + documentation
|
||||
- `scripts/` - TypeScript implementations
|
||||
- `prompts/system.md` - AI generation guidelines (optional)
|
||||
Each skill contains `SKILL.md` (YAML front matter + docs), optional `scripts/`, `references/`, `prompts/`.
|
||||
|
||||
Top-level `scripts/` contains repo maintenance utilities (sync, hooks, publish).
|
||||
|
||||
## Running Skills
|
||||
|
||||
All scripts run via Bun (no build step):
|
||||
|
||||
TypeScript via Bun (no build step). Detect runtime once per session:
|
||||
```bash
|
||||
npx -y bun skills/<skill>/scripts/main.ts [options]
|
||||
if command -v bun &>/dev/null; then BUN_X="bun"
|
||||
elif command -v npx &>/dev/null; then BUN_X="npx -y bun"
|
||||
else echo "Error: install bun: brew install oven-sh/bun/bun or npm install -g bun"; exit 1; fi
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Text generation
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Hello"
|
||||
|
||||
# Image generation
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
|
||||
# From prompt files
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
```
|
||||
Execute: `${BUN_X} skills/<skill>/scripts/main.ts [options]`
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- **Bun**: TypeScript runtime (via `npx -y bun`)
|
||||
- **Chrome**: Required for `baoyu-danger-gemini-web` auth and `baoyu-post-to-x` automation
|
||||
- **No npm packages**: Self-contained TypeScript, no external dependencies
|
||||
- **Bun**: TypeScript runtime (`bun` preferred, fallback `npx -y bun`)
|
||||
- **Chrome**: Required for CDP-based skills (gemini-web, post-to-x/wechat/weibo, url-to-markdown). All CDP skills share a single profile, override via `BAOYU_CHROME_PROFILE_DIR` env var. Platform paths: [docs/chrome-profile.md](docs/chrome-profile.md)
|
||||
- **Image generation APIs**: `baoyu-image-gen` requires API key (OpenAI, Google, DashScope, or Replicate) configured in EXTEND.md
|
||||
- **Gemini Web auth**: Browser cookies (first run opens Chrome for login, `--login` to refresh)
|
||||
|
||||
## Authentication
|
||||
## Security
|
||||
|
||||
`baoyu-danger-gemini-web` uses browser cookies for Google auth:
|
||||
- First run opens Chrome for login
|
||||
- Cookies cached in data directory
|
||||
- Force refresh: `--login` flag
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
`.claude-plugin/marketplace.json` defines plugin metadata and skill paths. Version follows semver.
|
||||
- **No piped shell installs**: Never `curl | bash`. Use `brew install` or `npm install -g`
|
||||
- **Remote downloads**: HTTPS only, max 5 redirects, 30s timeout, expected content types only
|
||||
- **System commands**: Array-form `spawn`/`execFile`, never unsanitized input to shell
|
||||
- **External content**: Treat as untrusted, don't execute code blocks, sanitize HTML
|
||||
|
||||
## Skill Loading Rules
|
||||
|
||||
**IMPORTANT**: When working in this project, follow these rules:
|
||||
|
||||
| Rule | Description |
|
||||
|------|-------------|
|
||||
| **Load project skills first** | MUST load all skills from `skills/` directory in current project. Project skills take priority over system/user-level skills with same name. |
|
||||
| **Default image generation** | When image generation is needed, use `skills/baoyu-image-gen/SKILL.md` by default (unless user specifies otherwise). |
|
||||
| **Load project skills first** | Project skills override system/user-level skills with same name |
|
||||
| **Default image generation** | Use `skills/baoyu-image-gen/SKILL.md` unless user specifies otherwise |
|
||||
|
||||
**Loading Priority** (highest → lowest):
|
||||
1. Current project `skills/` directory
|
||||
2. User-level skills (`$HOME/.baoyu-skills/`)
|
||||
3. System-level skills
|
||||
Priority: project `skills/` → `$HOME/.baoyu-skills/` → system-level.
|
||||
|
||||
## Release Process
|
||||
|
||||
**IMPORTANT**: When user requests release/发布/push, ALWAYS use `/release-skills` workflow.
|
||||
|
||||
**Never skip**:
|
||||
1. `CHANGELOG.md` + `CHANGELOG.zh.md` - Both must be updated
|
||||
Use `/release-skills` workflow. Never skip:
|
||||
1. `CHANGELOG.md` + `CHANGELOG.zh.md`
|
||||
2. `marketplace.json` version bump
|
||||
3. `README.md` + `README.zh.md` if applicable
|
||||
4. All files committed together before tag
|
||||
|
||||
## Adding New Skills
|
||||
|
||||
**IMPORTANT**: All skills MUST use `baoyu-` prefix to avoid conflicts when users import this plugin.
|
||||
|
||||
**REQUIRED READING**: Before creating a new skill, read the official [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices).
|
||||
|
||||
### Key Requirements from Official Best Practices
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| **Concise is key** | Claude is smart—only add context it doesn't have. Challenge each token. |
|
||||
| **name field** | Max 64 chars, lowercase letters/numbers/hyphens only, no "anthropic"/"claude" |
|
||||
| **description field** | Max 1024 chars, non-empty, MUST be third person, include what + when to use |
|
||||
| **SKILL.md body** | Keep under 500 lines; use separate files for additional content |
|
||||
| **Naming convention** | Gerund form preferred (e.g., `processing-pdfs`), but `baoyu-` prefix required here |
|
||||
| **References** | Keep one level deep from SKILL.md; avoid nested references |
|
||||
| **No time-sensitive info** | Avoid dates/versions that become outdated |
|
||||
|
||||
### Steps
|
||||
|
||||
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
||||
- Directory name: `baoyu-<name>`
|
||||
- SKILL.md `name` field: `baoyu-<name>`
|
||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/`
|
||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||
4. **Choose the appropriate category** and register in `marketplace.json`:
|
||||
- `content-skills`: For content generation/publishing (images, slides, posts)
|
||||
- `ai-generation-skills`: For AI backend capabilities
|
||||
- `utility-skills`: For helper tools (conversion, compression)
|
||||
- If none fit, create a new category with descriptive name
|
||||
5. **Add Script Directory section** to SKILL.md (see template below)
|
||||
|
||||
### Choosing a Category
|
||||
|
||||
| If your skill... | Use category |
|
||||
|------------------|--------------|
|
||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
||||
| Publishes to platforms (X, WeChat, etc.) | `content-skills` |
|
||||
| Provides AI generation backend | `ai-generation-skills` |
|
||||
| Converts or processes content | `utility-skills` |
|
||||
| Compresses or optimizes files | `utility-skills` |
|
||||
|
||||
**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`)
|
||||
- `description`: Brief description of the category
|
||||
- `skills`: Array with the skill path
|
||||
|
||||
### Writing Effective Descriptions
|
||||
|
||||
**MUST write in third person** (not "I can help you" or "You can use this"):
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
description: Generates Xiaohongshu infographic series from content. Use when user asks for "小红书图片", "XHS images", or "RedNote infographics".
|
||||
|
||||
# Bad
|
||||
description: I can help you create Xiaohongshu images
|
||||
description: You can use this to generate XHS content
|
||||
```
|
||||
|
||||
Include both **what** the skill does and **when** to use it (triggers/keywords).
|
||||
|
||||
### Script Directory Template
|
||||
|
||||
Every SKILL.md with scripts MUST include this section after Usage:
|
||||
|
||||
```markdown
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | Main entry point |
|
||||
| `scripts/other.ts` | Other functionality |
|
||||
```
|
||||
|
||||
When referencing scripts in workflow sections, use `${SKILL_DIR}/scripts/<name>.ts` so agents can resolve the correct path.
|
||||
|
||||
### Progressive Disclosure
|
||||
|
||||
For skills with extensive content, use separate reference files:
|
||||
|
||||
```
|
||||
skills/baoyu-example/
|
||||
├── SKILL.md # Main instructions (<500 lines)
|
||||
├── references/
|
||||
│ ├── styles.md # Style definitions (loaded as needed)
|
||||
│ └── examples.md # Usage examples (loaded as needed)
|
||||
└── scripts/
|
||||
└── main.ts # Executable script
|
||||
```
|
||||
|
||||
In SKILL.md, link to reference files (one level deep only):
|
||||
```markdown
|
||||
**Available styles**: See [references/styles.md](references/styles.md)
|
||||
**Examples**: See [references/examples.md](references/examples.md)
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- TypeScript throughout, no comments
|
||||
- Async/await patterns
|
||||
- Short variable names
|
||||
- Type-safe interfaces
|
||||
TypeScript, no comments, async/await, short variable names, type-safe interfaces.
|
||||
|
||||
## Image Generation Guidelines
|
||||
## Adding New Skills
|
||||
|
||||
Skills that require image generation MUST delegate to available image generation skills rather than implementing their own.
|
||||
All skills MUST use `baoyu-` prefix. Details: [docs/creating-skills.md](docs/creating-skills.md)
|
||||
|
||||
### Image Generation Skill Selection
|
||||
## Reference Docs
|
||||
|
||||
**Default**: Use `skills/baoyu-image-gen/SKILL.md` (unless user specifies otherwise).
|
||||
|
||||
1. Read `skills/baoyu-image-gen/SKILL.md` for parameters and capabilities
|
||||
2. If user explicitly requests a different skill, check `skills/` directory for alternatives
|
||||
3. Only ask user to choose when they haven't specified and multiple viable options exist
|
||||
|
||||
### Generation Flow Template
|
||||
|
||||
Use this template when implementing image generation in skills:
|
||||
|
||||
```markdown
|
||||
### Step N: Generate Images
|
||||
|
||||
**Skill Selection**:
|
||||
1. Check available image generation skills (e.g., `baoyu-danger-gemini-web`)
|
||||
2. Read selected skill's SKILL.md for parameter reference
|
||||
3. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call selected image generation skill with:
|
||||
- Prompt file path (or inline prompt)
|
||||
- Output image path
|
||||
- Any skill-specific parameters (refer to skill's SKILL.md)
|
||||
2. Generate images sequentially (one at a time)
|
||||
3. After each image, output progress: "Generated X/N"
|
||||
4. On failure, auto-retry once before reporting error
|
||||
```
|
||||
|
||||
### Output Path Convention
|
||||
|
||||
Each session creates an independent directory. Even the same source file generates a new directory per session.
|
||||
|
||||
**Output Directory**:
|
||||
```
|
||||
<skill-suffix>/<topic-slug>/
|
||||
```
|
||||
- `<skill-suffix>`: Skill name suffix (e.g., `xhs-images`, `cover-image`, `slide-deck`, `comic`)
|
||||
- `<topic-slug>`: Generated from content topic (2-4 words, kebab-case)
|
||||
- Example: `xhs-images/ai-future-trends/`
|
||||
|
||||
**Slug Generation**:
|
||||
1. Extract main topic from content (2-4 words, kebab-case)
|
||||
2. Example: "Introduction to Machine Learning" → `intro-machine-learning`
|
||||
|
||||
**Conflict Resolution**:
|
||||
If `<skill-suffix>/<topic-slug>/` already exists:
|
||||
- Append timestamp: `<topic-slug>-YYYYMMDD-HHMMSS`
|
||||
- Example: `ai-future` exists → `ai-future-20260118-143052`
|
||||
|
||||
**Source Files**:
|
||||
- Copy all sources to `<skill-suffix>/<topic-slug>/` with naming: `source-{slug}.{ext}`
|
||||
- Multiple sources supported: text, images, files from conversation
|
||||
- Examples:
|
||||
- `source-article.md` (main text content)
|
||||
- `source-reference.png` (image from conversation)
|
||||
- `source-data.csv` (additional file)
|
||||
- Original source files remain unchanged
|
||||
|
||||
### Image Naming Convention
|
||||
|
||||
Image filenames MUST include meaningful slugs for readability:
|
||||
|
||||
**Format**: `NN-{type}-[slug].png`
|
||||
- `NN`: Two-digit sequence number (01, 02, ...)
|
||||
- `{type}`: Image type (cover, content, page, slide, illustration, etc.)
|
||||
- `[slug]`: Descriptive kebab-case slug derived from content
|
||||
|
||||
**Examples**:
|
||||
```
|
||||
01-cover-ai-future.png
|
||||
02-content-key-benefits.png
|
||||
03-page-enigma-machine.png
|
||||
04-slide-architecture-overview.png
|
||||
```
|
||||
|
||||
**Slug Rules**:
|
||||
- Derived from image purpose or content (kebab-case)
|
||||
- Must be unique within the output directory
|
||||
- 2-5 words, concise but descriptive
|
||||
- When content changes significantly, update slug accordingly
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Always read the image generation skill's SKILL.md before calling
|
||||
- Pass parameters exactly as documented in the skill
|
||||
- Handle failures gracefully with retry logic
|
||||
- Provide clear progress feedback to user
|
||||
|
||||
## Style Maintenance (baoyu-comic)
|
||||
|
||||
When adding, updating, or deleting styles for `baoyu-comic`, follow this workflow:
|
||||
|
||||
### Adding a New Style
|
||||
|
||||
1. **Create style definition**: `skills/baoyu-comic/references/styles/<style-name>.md`
|
||||
2. **Update SKILL.md**:
|
||||
- Add style to `--style` options table
|
||||
- Add auto-selection entry if applicable
|
||||
3. **Generate showcase image**:
|
||||
```bash
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts \
|
||||
--prompt "A single comic book page in <style-name> style showing [appropriate scene]. Features: [style characteristics from style definition]. 3:4 portrait aspect ratio comic page." \
|
||||
--image screenshots/comic-styles/<style-name>.png
|
||||
```
|
||||
4. **Compress to WebP**:
|
||||
```bash
|
||||
npx -y bun skills/baoyu-compress-image/scripts/main.ts screenshots/comic-styles/<style-name>.png
|
||||
```
|
||||
5. **Update both READMEs** (`README.md` and `README.zh.md`):
|
||||
- Add style to `--style` options
|
||||
- Add row to style description table
|
||||
- Add image to style preview grid
|
||||
|
||||
### Updating an Existing Style
|
||||
|
||||
1. **Update style definition**: `skills/baoyu-comic/references/styles/<style-name>.md`
|
||||
2. **Regenerate showcase image** (if visual characteristics changed):
|
||||
- Follow steps 3-4 from "Adding a New Style"
|
||||
3. **Update READMEs** if description changed
|
||||
|
||||
### Deleting a Style
|
||||
|
||||
1. **Delete style definition**: `skills/baoyu-comic/references/styles/<style-name>.md`
|
||||
2. **Delete showcase image**: `screenshots/comic-styles/<style-name>.webp`
|
||||
3. **Update SKILL.md**:
|
||||
- Remove from `--style` options
|
||||
- Remove auto-selection entry
|
||||
4. **Update both READMEs**:
|
||||
- Remove from `--style` options
|
||||
- Remove from style description table
|
||||
- Remove from style preview grid
|
||||
|
||||
### Style Preview Grid Format
|
||||
|
||||
READMEs use 3-column tables for style previews:
|
||||
|
||||
```markdown
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| style1 | style2 | style3 |
|
||||
```
|
||||
|
||||
## Extension Support
|
||||
|
||||
Every SKILL.md MUST include two parts for extension support:
|
||||
|
||||
### 1. Load Preferences Section (in Step 1 or as "Preferences" section)
|
||||
|
||||
For skills with workflows, add as Step 1.1. For utility skills, add as "Preferences (EXTEND.md)" section before Usage:
|
||||
|
||||
```markdown
|
||||
**1.1 Load Preferences (EXTEND.md)**
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
|
||||
\`\`\`bash
|
||||
# Check project-level first
|
||||
test -f .baoyu-skills/<skill-name>/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
test -f "$HOME/.baoyu-skills/<skill-name>/EXTEND.md" && echo "user"
|
||||
\`\`\`
|
||||
|
||||
┌────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├────────────────────────────────────────────┼───────────────────┤
|
||||
│ .baoyu-skills/<skill-name>/EXTEND.md │ Project directory │
|
||||
├────────────────────────────────────────────┼───────────────────┤
|
||||
│ $HOME/.baoyu-skills/<skill-name>/EXTEND.md │ User home │
|
||||
└────────────────────────────────────────────┴───────────────────┘
|
||||
|
||||
┌───────────┬───────────────────────────────────────────────────────────────────────────┐
|
||||
│ Result │ Action │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Found │ Read, parse, display summary │
|
||||
├───────────┼───────────────────────────────────────────────────────────────────────────┤
|
||||
│ Not found │ Ask user with AskUserQuestion (see references/config/first-time-setup.md) │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: [List supported configuration options for this skill]
|
||||
|
||||
Schema: `references/config/preferences-schema.md`
|
||||
```
|
||||
|
||||
### 2. Extension Support Section (at the end)
|
||||
|
||||
Simplified section that references the preferences section:
|
||||
|
||||
```markdown
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md. See **Step 1.1** for paths and supported options.
|
||||
```
|
||||
|
||||
Or for utility skills without workflow steps:
|
||||
|
||||
```markdown
|
||||
## Extension Support
|
||||
|
||||
Custom configurations via EXTEND.md. See **Preferences** section for paths and supported options.
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
- Replace `<skill-name>` with actual skill name (e.g., `baoyu-cover-image`)
|
||||
- Use `$HOME` instead of `~` for cross-platform compatibility (macOS/Linux/WSL)
|
||||
- Use `test -f` Bash command for explicit file existence check
|
||||
- ASCII tables for clear visual formatting
|
||||
| Topic | File |
|
||||
|-------|------|
|
||||
| Image generation guidelines | [docs/image-generation.md](docs/image-generation.md) |
|
||||
| Chrome profile platform paths | [docs/chrome-profile.md](docs/chrome-profile.md) |
|
||||
| Comic style maintenance | [docs/comic-style-maintenance.md](docs/comic-style-maintenance.md) |
|
||||
| ClawHub/OpenClaw publishing | [docs/publishing.md](docs/publishing.md) |
|
||||
|
||||
@@ -17,6 +17,27 @@ Skills shared by Baoyu for improving daily work efficiency with Claude Code.
|
||||
npx skills add jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### Publish to ClawHub / OpenClaw
|
||||
|
||||
This repository now supports publishing each `skills/baoyu-*` directory as an individual ClawHub skill.
|
||||
|
||||
```bash
|
||||
# Preview what would be published
|
||||
./scripts/sync-clawhub.sh --dry-run
|
||||
|
||||
# Publish all changed skills from ./skills
|
||||
./scripts/sync-clawhub.sh --all
|
||||
```
|
||||
|
||||
ClawHub installs skills individually, not as one marketplace bundle. After publishing, users can install specific skills such as:
|
||||
|
||||
```bash
|
||||
clawhub install baoyu-image-gen
|
||||
clawhub install baoyu-markdown-to-html
|
||||
```
|
||||
|
||||
Publishing to ClawHub releases the published skill under `MIT-0`, per ClawHub's registry rules.
|
||||
|
||||
### Register as Plugin Marketplace
|
||||
|
||||
Run the following command in Claude Code:
|
||||
@@ -53,9 +74,9 @@ Simply tell Claude Code:
|
||||
|
||||
| Plugin | Description | Skills |
|
||||
|--------|-------------|--------|
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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
|
||||
|
||||
@@ -504,6 +525,8 @@ Smart article illustration skill with Type × Style two-dimension approach. Anal
|
||||
|
||||
Post content and articles to X (Twitter). Supports regular posts with images and X Articles (long-form Markdown). Uses real Chrome with CDP to bypass anti-automation.
|
||||
|
||||
Plain text input is treated as a regular post. Markdown files are treated as X Articles. Scripts fill content into the browser, and the user reviews and publishes manually.
|
||||
|
||||
```bash
|
||||
# Post with text
|
||||
/baoyu-post-to-x "Hello from Claude Code!"
|
||||
@@ -558,6 +581,83 @@ To obtain credentials:
|
||||
|
||||
**Browser Method** (no API setup needed): Requires Google Chrome. First run opens browser for QR code login (session preserved).
|
||||
|
||||
**Multi-Account Support**: Manage multiple WeChat Official Accounts via `EXTEND.md`:
|
||||
|
||||
```bash
|
||||
mkdir -p .baoyu-skills/baoyu-post-to-wechat
|
||||
```
|
||||
|
||||
Create `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`:
|
||||
|
||||
```yaml
|
||||
# Global settings (shared across all accounts)
|
||||
default_theme: default
|
||||
default_color: blue
|
||||
|
||||
# Account list
|
||||
accounts:
|
||||
- name: My Tech Blog
|
||||
alias: tech-blog
|
||||
default: false
|
||||
default_publish_method: api
|
||||
default_author: Author Name
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
app_id: wx1234567890abcdef
|
||||
app_secret: your_app_secret_here
|
||||
- name: AI Newsletter
|
||||
alias: ai-news
|
||||
default_publish_method: browser
|
||||
default_author: AI Newsletter
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
```
|
||||
|
||||
| Accounts configured | Behavior |
|
||||
|---------------------|----------|
|
||||
| No `accounts` block | Single-account mode (backward compatible) |
|
||||
| 1 account | Auto-select, no prompt |
|
||||
| 2+ accounts | Prompt to select, or use `--account <alias>` |
|
||||
| 1 account has `default: true` | Pre-selected as default |
|
||||
|
||||
Each account gets an isolated Chrome profile for independent login sessions (browser method). API credentials can be set inline in EXTEND.md or via `.env` with alias-prefixed keys (e.g., `WECHAT_TECH_BLOG_APP_ID`).
|
||||
|
||||
#### baoyu-post-to-weibo
|
||||
|
||||
Post content to Weibo (微博). Supports regular posts with text, images, and videos, and headline articles (头条文章) with Markdown input. Uses real Chrome with CDP to bypass anti-automation.
|
||||
|
||||
**Regular Posts** - Text + images/videos (max 18 files):
|
||||
|
||||
```bash
|
||||
# Post with text
|
||||
/baoyu-post-to-weibo "Hello Weibo!"
|
||||
|
||||
# Post with images
|
||||
/baoyu-post-to-weibo "Check this out" --image photo.png
|
||||
|
||||
# Post with video
|
||||
/baoyu-post-to-weibo "Watch this" --video clip.mp4
|
||||
```
|
||||
|
||||
**Headline Articles (头条文章)** - Long-form Markdown:
|
||||
|
||||
```bash
|
||||
# Publish article
|
||||
/baoyu-post-to-weibo --article article.md
|
||||
|
||||
# With cover image
|
||||
/baoyu-post-to-weibo --article article.md --cover cover.jpg
|
||||
```
|
||||
|
||||
**Article Options**:
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--cover <path>` | Cover image |
|
||||
| `--title <text>` | Override title (max 32 chars) |
|
||||
| `--summary <text>` | Override summary (max 44 chars) |
|
||||
|
||||
**Note**: Scripts fill content into the browser. User reviews and publishes manually. First run requires manual Weibo login (session persists).
|
||||
|
||||
### AI Generation Skills
|
||||
|
||||
AI-powered generation backends.
|
||||
@@ -641,7 +741,7 @@ Utility tools for content processing.
|
||||
|
||||
#### baoyu-url-to-markdown
|
||||
|
||||
Fetch any URL via Chrome CDP and convert to clean markdown. Supports two capture modes for different scenarios.
|
||||
Fetch any URL via Chrome CDP and convert to clean markdown. Saves rendered HTML snapshot alongside the markdown, and automatically falls back to a legacy extractor when Defuddle fails.
|
||||
|
||||
```bash
|
||||
# Auto mode (default) - capture when page loads
|
||||
@@ -739,6 +839,101 @@ Format plain text or markdown files with proper frontmatter, titles, summaries,
|
||||
| Code/commands | `` `inline` `` or ` ```block``` ` |
|
||||
| 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
|
||||
|
||||
Translate articles and documents between languages with three modes: quick (direct), normal (analysis-informed), and refined (full publication-quality workflow with review and polish).
|
||||
|
||||
```bash
|
||||
# Normal mode (default) - analyze then translate
|
||||
/translate article.md --to zh-CN
|
||||
|
||||
# Quick mode - direct translation
|
||||
/translate article.md --mode quick --to ja
|
||||
|
||||
# Refined mode - full workflow with review and polish
|
||||
/translate article.md --mode refined --to zh-CN
|
||||
|
||||
# Translate a URL
|
||||
/translate https://example.com/article --to zh-CN
|
||||
|
||||
# Specify audience
|
||||
/translate article.md --to zh-CN --audience technical
|
||||
|
||||
# Specify style
|
||||
/translate article.md --to zh-CN --style humorous
|
||||
|
||||
# With additional glossary
|
||||
/translate article.md --to zh-CN --glossary my-terms.md
|
||||
```
|
||||
|
||||
**Options**:
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `<source>` | File path, URL, or inline text |
|
||||
| `--mode <mode>` | `quick`, `normal` (default), `refined` |
|
||||
| `--from <lang>` | Source language (auto-detect if omitted) |
|
||||
| `--to <lang>` | Target language (default: `zh-CN`) |
|
||||
| `--audience <type>` | Target reader profile (default: `general`) |
|
||||
| `--style <style>` | Translation style (default: `storytelling`) |
|
||||
| `--glossary <file>` | Additional glossary file |
|
||||
|
||||
**Modes**:
|
||||
| Mode | Steps | Use Case |
|
||||
|------|-------|----------|
|
||||
| Quick | Translate | Short texts, informal content |
|
||||
| Normal | Analyze → Translate | Articles, blog posts |
|
||||
| Refined | Analyze → Translate → Review → Polish | Publication-quality documents |
|
||||
|
||||
After normal mode completes, you can reply "继续润色" or "refine" to continue with review and polish steps.
|
||||
|
||||
**Audience Presets**:
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `general` | General readers (default) — plain language, more translator's notes |
|
||||
| `technical` | Developers / engineers — less annotation on common tech terms |
|
||||
| `academic` | Researchers / scholars — formal register, precise terminology |
|
||||
| `business` | Business professionals — business-friendly tone |
|
||||
|
||||
Custom audience descriptions are also accepted, e.g., `--audience "AI-interested general readers"`.
|
||||
|
||||
**Style Presets**:
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `storytelling` | Engaging narrative flow (default) — smooth transitions, vivid phrasing |
|
||||
| `formal` | Professional, structured — neutral tone, no colloquialisms |
|
||||
| `technical` | Precise, documentation-style — concise, terminology-heavy |
|
||||
| `literal` | Close to original structure — minimal restructuring |
|
||||
| `academic` | Scholarly, rigorous — formal register, complex clauses OK |
|
||||
| `business` | Concise, results-focused — action-oriented, executive-friendly |
|
||||
| `humorous` | Preserves and adapts humor — witty, recreates comedic effect |
|
||||
| `conversational` | Casual, spoken-like — friendly, as if explaining to a friend |
|
||||
| `elegant` | Literary, polished prose — aesthetically refined, carefully crafted |
|
||||
|
||||
Custom style descriptions are also accepted, e.g., `--style "poetic and lyrical"`.
|
||||
|
||||
**Features**:
|
||||
- Custom glossaries via EXTEND.md with built-in EN→ZH glossary
|
||||
- Audience-aware translation with adjustable annotation depth
|
||||
- Automatic chunking for long documents (4000+ words) with parallel subagent translation
|
||||
- Figurative language interpreted by meaning, not word-for-word
|
||||
- Translator's notes for cultural/domain-specific references
|
||||
- Output directory with all intermediate files preserved
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Some skills require API keys or custom configuration. Environment variables can be set in `.env` files:
|
||||
@@ -842,6 +1037,19 @@ This skill uses a reverse-engineered X (Twitter) API.
|
||||
- First use requires consent acknowledgment
|
||||
- Authentication via environment variables or Chrome login
|
||||
|
||||
## Credits
|
||||
|
||||
This project was inspired by and builds upon the following open source projects:
|
||||
|
||||
- [x-article-publisher-skill](https://github.com/wshuyi/x-article-publisher-skill) by [@wshuyi](https://github.com/wshuyi) — Inspiration for the X article publishing skill
|
||||
- [doocs/md](https://github.com/doocs/md) by [@doocs](https://github.com/doocs) — Core implementation logic for Markdown to HTML conversion
|
||||
- [High-density Infographic Prompt](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg) by AJ@WaytoAGI — Inspiration for the infographic skill
|
||||
- [qiaomu-mondo-poster-design](https://github.com/joeseesun/qiaomu-mondo-poster-design) by [@joeseesun](https://github.com/joeseesun)(乔木) — Inspiration for the Mondo style
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#JimLiu/baoyu-skills&Date)
|
||||
|
||||
+211
-3
@@ -17,6 +17,27 @@
|
||||
npx skills add jimliu/baoyu-skills
|
||||
```
|
||||
|
||||
### 发布到 ClawHub / OpenClaw
|
||||
|
||||
现在这个仓库支持把每个 `skills/baoyu-*` 目录作为独立 ClawHub skill 发布。
|
||||
|
||||
```bash
|
||||
# 预览将要发布的变更
|
||||
./scripts/sync-clawhub.sh --dry-run
|
||||
|
||||
# 发布 ./skills 下所有已变更的 skill
|
||||
./scripts/sync-clawhub.sh --all
|
||||
```
|
||||
|
||||
ClawHub 按“单个 skill”安装,不是把整个 marketplace 一次性装进去。发布后,用户可以按需安装:
|
||||
|
||||
```bash
|
||||
clawhub install baoyu-image-gen
|
||||
clawhub install baoyu-markdown-to-html
|
||||
```
|
||||
|
||||
根据 ClawHub 的 registry 规则,发布到 ClawHub 的 skill 会以 `MIT-0` 许可分发。
|
||||
|
||||
### 注册插件市场
|
||||
|
||||
在 Claude Code 中运行:
|
||||
@@ -53,9 +74,9 @@ npx skills add jimliu/baoyu-skills
|
||||
|
||||
| 插件 | 说明 | 包含技能 |
|
||||
|------|------|----------|
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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) |
|
||||
| **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) |
|
||||
|
||||
## 更新技能
|
||||
|
||||
@@ -504,6 +525,8 @@ npx skills add jimliu/baoyu-skills
|
||||
|
||||
发布内容和文章到 X (Twitter)。支持带图片的普通帖子和 X 文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
||||
|
||||
纯文本输入默认按普通帖子处理,Markdown 文件默认按 X 文章处理。脚本会将内容填入浏览器,用户需手动检查并发布。
|
||||
|
||||
```bash
|
||||
# 发布文字
|
||||
/baoyu-post-to-x "Hello from Claude Code!"
|
||||
@@ -558,6 +581,83 @@ WECHAT_APP_SECRET=你的AppSecret
|
||||
|
||||
**浏览器方式**(无需 API 配置):需已安装 Google Chrome,首次运行需扫码登录(登录状态会保存)
|
||||
|
||||
**多账号支持**:通过 `EXTEND.md` 管理多个微信公众号:
|
||||
|
||||
```bash
|
||||
mkdir -p .baoyu-skills/baoyu-post-to-wechat
|
||||
```
|
||||
|
||||
创建 `.baoyu-skills/baoyu-post-to-wechat/EXTEND.md`:
|
||||
|
||||
```yaml
|
||||
# 全局设置(所有账号共享)
|
||||
default_theme: default
|
||||
default_color: blue
|
||||
|
||||
# 账号列表
|
||||
accounts:
|
||||
- name: 宝玉的技术分享
|
||||
alias: baoyu
|
||||
default: false
|
||||
default_publish_method: api
|
||||
default_author: 宝玉
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
app_id: wx1234567890abcdef
|
||||
app_secret: 你的 AppSecret
|
||||
- name: AI 工具集
|
||||
alias: ai-tools
|
||||
default_publish_method: browser
|
||||
default_author: AI 工具集
|
||||
need_open_comment: 1
|
||||
only_fans_can_comment: 0
|
||||
```
|
||||
|
||||
| 账号配置情况 | 行为 |
|
||||
|-------------|------|
|
||||
| 无 `accounts` 块 | 单账号模式(向后兼容) |
|
||||
| 1 个账号 | 自动选择,无需提示 |
|
||||
| 2+ 个账号 | 提示选择,或使用 `--account <别名>` |
|
||||
| 某账号设置 `default: true` | 预选为默认账号 |
|
||||
|
||||
每个账号拥有独立的 Chrome 配置目录,保证浏览器方式下的登录会话互不干扰。API 凭证可在 EXTEND.md 中直接配置,也可通过 `.env` 文件使用别名前缀的环境变量(如 `WECHAT_BAOYU_APP_ID`)。
|
||||
|
||||
#### baoyu-post-to-weibo
|
||||
|
||||
发布内容到微博。支持文字、图片、视频发布和头条文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。
|
||||
|
||||
**普通微博** - 文字 + 图片/视频(最多 18 个文件):
|
||||
|
||||
```bash
|
||||
# 发布文字
|
||||
/baoyu-post-to-weibo "Hello Weibo!"
|
||||
|
||||
# 发布带图片
|
||||
/baoyu-post-to-weibo "看看这个" --image photo.png
|
||||
|
||||
# 发布带视频
|
||||
/baoyu-post-to-weibo "看这个" --video clip.mp4
|
||||
```
|
||||
|
||||
**头条文章** - 长篇 Markdown 文章:
|
||||
|
||||
```bash
|
||||
# 发布文章
|
||||
/baoyu-post-to-weibo --article article.md
|
||||
|
||||
# 带封面图
|
||||
/baoyu-post-to-weibo --article article.md --cover cover.jpg
|
||||
```
|
||||
|
||||
**文章选项**:
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `--cover <path>` | 封面图 |
|
||||
| `--title <text>` | 覆盖标题(最多 32 字) |
|
||||
| `--summary <text>` | 覆盖摘要(最多 44 字) |
|
||||
|
||||
**说明**:脚本会将内容填入浏览器,用户需手动检查并发布。首次运行需手动登录微博(登录状态会保存)。
|
||||
|
||||
### AI 生成技能 (AI Generation Skills)
|
||||
|
||||
AI 驱动的生成后端。
|
||||
@@ -641,7 +741,7 @@ AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-url-to-markdown
|
||||
|
||||
通过 Chrome CDP 抓取任意 URL 并转换为干净的 Markdown。支持两种抓取模式,适应不同场景。
|
||||
通过 Chrome CDP 抓取任意 URL 并转换为 Markdown。同时保存渲染后的 HTML 快照,Defuddle 失败时自动回退到旧版提取器。
|
||||
|
||||
```bash
|
||||
# 自动模式(默认)- 页面加载后立即抓取
|
||||
@@ -739,6 +839,101 @@ 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
|
||||
|
||||
三模式翻译技能:快速(直接翻译)、标准(分析后翻译)、精翻(完整出版级工作流,含审校与润色)。
|
||||
|
||||
```bash
|
||||
# 标准模式(默认)- 先分析再翻译
|
||||
/translate article.md --to zh-CN
|
||||
|
||||
# 快速模式 - 直接翻译
|
||||
/translate article.md --mode quick --to ja
|
||||
|
||||
# 精翻模式 - 完整工作流,含审校与润色
|
||||
/translate article.md --mode refined --to zh-CN
|
||||
|
||||
# 翻译 URL
|
||||
/translate https://example.com/article --to zh-CN
|
||||
|
||||
# 指定受众
|
||||
/translate article.md --to zh-CN --audience technical
|
||||
|
||||
# 指定风格
|
||||
/translate article.md --to zh-CN --style humorous
|
||||
|
||||
# 附加术语表
|
||||
/translate article.md --to zh-CN --glossary my-terms.md
|
||||
```
|
||||
|
||||
**选项**:
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `<source>` | 文件路径、URL 或行内文本 |
|
||||
| `--mode <mode>` | `quick`、`normal`(默认)、`refined` |
|
||||
| `--from <lang>` | 源语言(省略则自动检测) |
|
||||
| `--to <lang>` | 目标语言(默认:`zh-CN`) |
|
||||
| `--audience <type>` | 目标读者(默认:`general`) |
|
||||
| `--style <style>` | 翻译风格(默认:`storytelling`) |
|
||||
| `--glossary <file>` | 附加术语表文件 |
|
||||
|
||||
**模式**:
|
||||
| 模式 | 步骤 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| 快速 | 翻译 | 短文本、非正式内容 |
|
||||
| 标准 | 分析 → 翻译 | 文章、博客 |
|
||||
| 精翻 | 分析 → 翻译 → 审校 → 润色 | 出版级文档 |
|
||||
|
||||
标准模式完成后,可回复「继续润色」或「refine」继续审校润色步骤。
|
||||
|
||||
**受众预设**:
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `general` | 普通读者(默认)— 通俗语言,更多译注 |
|
||||
| `technical` | 开发者/工程师 — 常见技术术语少加注释 |
|
||||
| `academic` | 研究者/学者 — 正式语体,精确术语 |
|
||||
| `business` | 商务人士 — 商务友好语气 |
|
||||
|
||||
也支持自定义受众描述,如 `--audience "对 AI 感兴趣的普通读者"`。
|
||||
|
||||
**风格预设**:
|
||||
| 值 | 说明 |
|
||||
|----|------|
|
||||
| `storytelling` | 叙事流畅(默认)— 过渡自然,表达生动 |
|
||||
| `formal` | 正式、结构化 — 中性语气,无口语化表达 |
|
||||
| `technical` | 精确、文档风格 — 简洁,术语密集 |
|
||||
| `literal` | 贴近原文结构 — 最小化重构 |
|
||||
| `academic` | 学术、严谨 — 正式语体,复杂从句可接受 |
|
||||
| `business` | 简洁、结果导向 — 行动导向,高管友好 |
|
||||
| `humorous` | 保留幽默感 — 诙谐,在目标语言中重现喜剧效果 |
|
||||
| `conversational` | 口语化、亲切 — 友好,如同朋友间解释 |
|
||||
| `elegant` | 文学性、优雅 — 精心雕琢,注重韵律美感 |
|
||||
|
||||
也支持自定义风格描述,如 `--style "诗意而抒情"`。
|
||||
|
||||
**特性**:
|
||||
- 通过 EXTEND.md 自定义术语表,内置英中术语表
|
||||
- 面向受众的翻译,可调节注释深度
|
||||
- 长文档(4000+ 词)自动分块并行翻译
|
||||
- 比喻和修辞按意译而非逐字翻译
|
||||
- 为文化/专业术语添加译注
|
||||
- 输出目录保留所有中间文件
|
||||
|
||||
## 环境配置
|
||||
|
||||
部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置:
|
||||
@@ -842,6 +1037,19 @@ HTTP_PROXY=http://127.0.0.1:7890 HTTPS_PROXY=http://127.0.0.1:7890 /baoyu-danger
|
||||
- 首次使用需确认免责声明
|
||||
- 通过环境变量或 Chrome 登录进行身份验证
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目受到以下开源项目的启发,感谢它们的作者:
|
||||
|
||||
- [x-article-publisher-skill](https://github.com/wshuyi/x-article-publisher-skill) by [@wshuyi](https://github.com/wshuyi) — 发布 X 文章技能的灵感来源
|
||||
- [doocs/md](https://github.com/doocs/md) by [@doocs](https://github.com/doocs) — Markdown 转 HTML 的核心实现逻辑
|
||||
- [高密度信息图 Prompt](https://waytoagi.feishu.cn/wiki/YG0zwalijihRREkgmPzcWRInnUg) by AJ@WaytoAGI — 信息图技能的灵感来源
|
||||
- [qiaomu-mondo-poster-design](https://github.com/joeseesun/qiaomu-mondo-poster-design) by [@joeseesun](https://github.com/joeseesun)(乔木) — Mondo 风格的灵感来源
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#JimLiu/baoyu-skills&Date)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Chrome Profile
|
||||
|
||||
All CDP skills share a single profile directory. Do NOT create per-skill profiles.
|
||||
|
||||
Override: `BAOYU_CHROME_PROFILE_DIR` env var (takes priority over all defaults).
|
||||
|
||||
| Platform | Default Path |
|
||||
|----------|-------------|
|
||||
| macOS | `~/Library/Application Support/baoyu-skills/chrome-profile` |
|
||||
| Linux | `$XDG_DATA_HOME/baoyu-skills/chrome-profile` (fallback `~/.local/share/`) |
|
||||
| Windows | `%APPDATA%/baoyu-skills/chrome-profile` |
|
||||
| WSL | Windows home `/.local/share/baoyu-skills/chrome-profile` |
|
||||
|
||||
New skills: use `BAOYU_CHROME_PROFILE_DIR` only (not per-skill env vars like `X_BROWSER_PROFILE_DIR`).
|
||||
|
||||
## Implementation Pattern
|
||||
|
||||
```typescript
|
||||
function getDefaultProfileDir(): string {
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
const base = process.platform === 'darwin'
|
||||
? path.join(os.homedir(), 'Library', 'Application Support')
|
||||
: process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
|
||||
return path.join(base, 'baoyu-skills', 'chrome-profile');
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# Style Maintenance (baoyu-comic)
|
||||
|
||||
## Adding a New Style
|
||||
|
||||
1. Create style definition: `skills/baoyu-comic/references/styles/<style-name>.md`
|
||||
2. Update SKILL.md: add to `--style` options table + auto-selection entry
|
||||
3. Generate showcase image:
|
||||
```bash
|
||||
${BUN_X} skills/baoyu-danger-gemini-web/scripts/main.ts \
|
||||
--prompt "A single comic book page in <style-name> style showing [scene]. Features: [characteristics]. 3:4 portrait aspect ratio comic page." \
|
||||
--image screenshots/comic-styles/<style-name>.png
|
||||
```
|
||||
4. Compress: `${BUN_X} skills/baoyu-compress-image/scripts/main.ts screenshots/comic-styles/<style-name>.png`
|
||||
5. Update both READMEs (`README.md` + `README.zh.md`): add style to options, description table, preview grid
|
||||
|
||||
## Updating an Existing Style
|
||||
|
||||
1. Update style definition in `references/styles/`
|
||||
2. Regenerate showcase image if visual characteristics changed (steps 3-4 above)
|
||||
3. Update READMEs if description changed
|
||||
|
||||
## Deleting a Style
|
||||
|
||||
1. Delete style definition + showcase image (`.webp`)
|
||||
2. Remove from SKILL.md `--style` options + auto-selection
|
||||
3. Remove from both READMEs (options, description table, preview grid)
|
||||
|
||||
## Style Preview Grid Format
|
||||
|
||||
```markdown
|
||||
| | | |
|
||||
|:---:|:---:|:---:|
|
||||
|  |  |  |
|
||||
| style1 | style2 | style3 |
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
# Creating New Skills
|
||||
|
||||
**REQUIRED READING**: [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
|
||||
|
||||
## Key Requirements
|
||||
|
||||
| Requirement | Details |
|
||||
|-------------|---------|
|
||||
| **Prefix** | All skills MUST use `baoyu-` prefix |
|
||||
| **name field** | Max 64 chars, lowercase letters/numbers/hyphens only, no "anthropic"/"claude" |
|
||||
| **description** | Max 1024 chars, third person, include what + when to use |
|
||||
| **SKILL.md body** | Keep under 500 lines; use `references/` for additional content |
|
||||
| **References** | One level deep from SKILL.md; avoid nested references |
|
||||
|
||||
## 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
|
||||
|
||||
1. Create `skills/baoyu-<name>/SKILL.md` with YAML front matter
|
||||
2. Add TypeScript in `skills/baoyu-<name>/scripts/` (if applicable)
|
||||
3. Add prompt templates in `skills/baoyu-<name>/prompts/` if needed
|
||||
4. Register in `marketplace.json` under appropriate category
|
||||
5. Add Script Directory section to SKILL.md if skill has scripts
|
||||
6. Add openclaw metadata to frontmatter
|
||||
|
||||
## Category Selection
|
||||
|
||||
| If your skill... | Use category |
|
||||
|------------------|--------------|
|
||||
| Generates visual content (images, slides, comics) | `content-skills` |
|
||||
| Publishes to platforms (X, WeChat, Weibo) | `content-skills` |
|
||||
| Provides AI generation backend | `ai-generation-skills` |
|
||||
| Converts or processes content | `utility-skills` |
|
||||
|
||||
New category: add plugin object to `marketplace.json` with `name`, `description`, `skills[]`.
|
||||
|
||||
## Writing Descriptions
|
||||
|
||||
**MUST write in third person**:
|
||||
|
||||
```yaml
|
||||
# Good
|
||||
description: Generates Xiaohongshu infographic series from content. Use when user asks for "小红书图片", "XHS images".
|
||||
|
||||
# Bad
|
||||
description: I can help you create Xiaohongshu images
|
||||
```
|
||||
|
||||
## Script Directory Template
|
||||
|
||||
Every SKILL.md with scripts MUST include:
|
||||
|
||||
```markdown
|
||||
## Script Directory
|
||||
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `{baseDir}`
|
||||
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
|
||||
4. Replace all `{baseDir}` and `${BUN_X}` in this document with actual values
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/main.ts` | Main entry point |
|
||||
```
|
||||
|
||||
## Progressive Disclosure
|
||||
|
||||
For skills with extensive content:
|
||||
|
||||
```
|
||||
skills/baoyu-example/
|
||||
├── SKILL.md # Main instructions (<500 lines)
|
||||
├── references/
|
||||
│ ├── styles.md # Loaded as needed
|
||||
│ └── examples.md # Loaded as needed
|
||||
└── scripts/
|
||||
└── main.ts
|
||||
```
|
||||
|
||||
Link from SKILL.md (one level deep only):
|
||||
```markdown
|
||||
**Available styles**: See [references/styles.md](references/styles.md)
|
||||
```
|
||||
|
||||
## Extension Support (EXTEND.md)
|
||||
|
||||
Every SKILL.md MUST include EXTEND.md loading. Add as Step 1.1 (workflow skills) or "Preferences" section (utility skills):
|
||||
|
||||
```markdown
|
||||
**1.1 Load Preferences (EXTEND.md)**
|
||||
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
\`\`\`bash
|
||||
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"
|
||||
\`\`\`
|
||||
|
||||
| Path | Location |
|
||||
|------|----------|
|
||||
| `.baoyu-skills/<skill-name>/EXTEND.md` | Project directory |
|
||||
| `$XDG_CONFIG_HOME/baoyu-skills/<skill-name>/EXTEND.md` | XDG config (~/.config) |
|
||||
| `$HOME/.baoyu-skills/<skill-name>/EXTEND.md` | User home (legacy) |
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary |
|
||||
| Not found | Ask user with AskUserQuestion |
|
||||
```
|
||||
|
||||
End of SKILL.md should include:
|
||||
```markdown
|
||||
## Extension Support
|
||||
Custom configurations via EXTEND.md. See **Step 1.1** for paths and supported options.
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# Image Generation Guidelines
|
||||
|
||||
Skills that require image generation MUST delegate to available image generation skills.
|
||||
|
||||
## Skill Selection
|
||||
|
||||
**Default**: `skills/baoyu-image-gen/SKILL.md` (unless user specifies otherwise).
|
||||
|
||||
1. Read skill's SKILL.md for parameters and capabilities
|
||||
2. If user requests different skill, check `skills/` for alternatives
|
||||
3. Only ask user when multiple viable options exist
|
||||
|
||||
## Generation Flow Template
|
||||
|
||||
```markdown
|
||||
### Step N: Generate Images
|
||||
|
||||
**Skill Selection**:
|
||||
1. Check available skills (`baoyu-image-gen` default, or `baoyu-danger-gemini-web`)
|
||||
2. Read selected skill's SKILL.md for parameters
|
||||
3. If multiple skills available, ask user to choose
|
||||
|
||||
**Generation Flow**:
|
||||
1. Call skill with prompt, output path, and skill-specific parameters
|
||||
2. Generate sequentially by default (batch parallel only when user has multiple prompts)
|
||||
3. Output progress: "Generated X/N"
|
||||
4. On failure, auto-retry once before reporting error
|
||||
```
|
||||
|
||||
**Batch Parallel** (`baoyu-image-gen` only): concurrent workers with per-provider throttling via `batch.max_workers` in EXTEND.md.
|
||||
|
||||
## Output Path Convention
|
||||
|
||||
**Output Directory**: `<skill-suffix>/<topic-slug>/`
|
||||
- `<skill-suffix>`: e.g., `xhs-images`, `cover-image`, `slide-deck`, `comic`
|
||||
- `<topic-slug>`: 2-4 words, kebab-case from content topic
|
||||
- Conflict: append timestamp `<topic-slug>-YYYYMMDD-HHMMSS`
|
||||
|
||||
**Source Files**: Copy to output dir as `source-{slug}.{ext}`
|
||||
|
||||
## Image Naming Convention
|
||||
|
||||
**Format**: `NN-{type}-[slug].png`
|
||||
- `NN`: Two-digit sequence (01, 02, ...)
|
||||
- `{type}`: cover, content, page, slide, illustration, etc.
|
||||
- `[slug]`: 2-5 word kebab-case descriptor, unique within directory
|
||||
|
||||
Examples:
|
||||
```
|
||||
01-cover-ai-future.png
|
||||
02-content-key-benefits.png
|
||||
03-slide-architecture-overview.png
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# ClawHub / OpenClaw Publishing
|
||||
|
||||
## OpenClaw Metadata
|
||||
|
||||
Skills include `metadata.openclaw` in YAML front matter:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#<skill-name>
|
||||
requires: # only for skills with scripts
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
```
|
||||
|
||||
## Publishing Commands
|
||||
|
||||
```bash
|
||||
bash scripts/sync-clawhub.sh # sync all skills
|
||||
bash scripts/sync-clawhub.sh <skill> # sync one skill
|
||||
```
|
||||
|
||||
Release hooks are configured via `.releaserc.yml`. This repo does not stage a separate release directory: release prep only syncs `packages/` into each skill's committed `scripts/vendor/`, and publish reads the skill directory directly.
|
||||
|
||||
## Shared Workspace Packages
|
||||
|
||||
`packages/` is the **only** source of truth. Never edit `skills/*/scripts/vendor/` directly.
|
||||
|
||||
Current package: `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`).
|
||||
|
||||
**How it works**: Sync script copies packages into each consuming skill's `vendor/` directory and rewrites dependency specs to `file:./vendor/<name>`. Vendor copies are committed to git, making skills self-contained.
|
||||
|
||||
**Update workflow**:
|
||||
1. Edit package under `packages/`
|
||||
2. Run `node scripts/sync-shared-skill-packages.mjs`
|
||||
3. Commit synced `vendor/`, `package.json`, and `bun.lock` together
|
||||
|
||||
**Git hook**: Run `node scripts/install-git-hooks.mjs` once to enable the `pre-push` hook. It re-syncs and blocks push if vendor copies are stale (`--enforce-clean`).
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "baoyu-chrome-cdp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
win32?: string[];
|
||||
default: string[];
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type CdpSendOptions = {
|
||||
sessionId?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type FetchJsonOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type FindChromeExecutableOptions = {
|
||||
candidates: PlatformCandidates;
|
||||
envNames?: string[];
|
||||
};
|
||||
|
||||
type ResolveSharedChromeProfileDirOptions = {
|
||||
envNames?: string[];
|
||||
appDataDirName?: string;
|
||||
profileDirName?: string;
|
||||
wslWindowsHome?: string | null;
|
||||
};
|
||||
|
||||
type FindExistingChromeDebugPortOptions = {
|
||||
profileDir: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
port: number;
|
||||
url?: string;
|
||||
headless?: boolean;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
|
||||
type ChromeTargetInfo = {
|
||||
targetId: string;
|
||||
url: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type OpenPageSessionOptions = {
|
||||
cdp: CdpConnection;
|
||||
reusing: boolean;
|
||||
url: string;
|
||||
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||
enablePage?: boolean;
|
||||
enableRuntime?: boolean;
|
||||
enableDom?: boolean;
|
||||
enableNetwork?: boolean;
|
||||
activateTarget?: boolean;
|
||||
};
|
||||
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||
for (const envName of options.envNames ?? []) {
|
||||
const override = process.env[envName]?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
}
|
||||
|
||||
const candidates = process.platform === "darwin"
|
||||
? options.candidates.darwin ?? options.candidates.default
|
||||
: process.platform === "win32"
|
||||
? options.candidates.win32 ?? options.candidates.default
|
||||
: options.candidates.default;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||
for (const envName of options.envNames ?? []) {
|
||||
const override = process.env[envName]?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
}
|
||||
|
||||
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||
|
||||
if (options.wslWindowsHome) {
|
||||
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||
}
|
||||
|
||||
const base = process.platform === "darwin"
|
||||
? path.join(os.homedir(), "Library", "Application Support")
|
||||
: process.platform === "win32"
|
||||
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||
return path.join(base, appDataDirName, profileDirName);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
{ timeoutMs }
|
||||
);
|
||||
return !!version.webSocketDebuggerUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
options?: { includeLastError?: boolean }
|
||||
): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
{ timeoutMs: 5_000 }
|
||||
);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (options?.includeLastError && lastError) {
|
||||
throw new Error(
|
||||
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||
);
|
||||
}
|
||||
throw new Error("Chrome debug port not ready");
|
||||
}
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
private defaultTimeoutMs: number;
|
||||
|
||||
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||
this.ws = ws;
|
||||
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data = typeof event.data === "string"
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(msg.params));
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error("CDP connection closed."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
options?: { defaultTimeoutMs?: number }
|
||||
): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("CDP connection failed."));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
if (!this.eventHandlers.has(method)) {
|
||||
this.eventHandlers.set(method, new Set());
|
||||
}
|
||||
this.eventHandlers.get(method)?.add(handler);
|
||||
}
|
||||
|
||||
off(method: string, handler: (params: unknown) => void): void {
|
||||
this.eventHandlers.get(method)?.delete(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${options.port}`,
|
||||
`--user-data-dir=${options.profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
...(options.extraArgs ?? []),
|
||||
];
|
||||
if (options.headless) args.push("--headless=new");
|
||||
if (options.url) args.push(options.url);
|
||||
|
||||
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
export function killChrome(chrome: ChildProcess): void {
|
||||
try {
|
||||
chrome.kill("SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill("SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
}
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
if (existing) {
|
||||
targetId = existing.targetId;
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||
"Target.attachToTarget",
|
||||
{ targetId, flatten: true }
|
||||
);
|
||||
|
||||
if (options.activateTarget ?? true) {
|
||||
await options.cdp.send("Target.activateTarget", { targetId });
|
||||
}
|
||||
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
async function main() {
|
||||
const repoRoot = path.resolve(process.cwd());
|
||||
const hooksPath = path.join(repoRoot, ".githooks");
|
||||
|
||||
const result = spawnSync("git", ["config", "core.hooksPath", hooksPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error("Failed to configure core.hooksPath");
|
||||
}
|
||||
|
||||
console.log(`Configured git hooks path: ${hooksPath}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const PACKAGE_DEPENDENCY_SECTIONS = [
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"devDependencies",
|
||||
];
|
||||
|
||||
const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules", "out", "dist", "build"]);
|
||||
const SKIPPED_FILES = new Set([".DS_Store", "bun.lockb"]);
|
||||
|
||||
export async function listReleaseFiles(root) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const files = [];
|
||||
|
||||
async function walk(folder) {
|
||||
const entries = await fs.readdir(folder, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && SKIPPED_DIRS.has(entry.name)) continue;
|
||||
if (entry.isFile() && SKIPPED_FILES.has(entry.name)) continue;
|
||||
|
||||
const fullPath = path.join(folder, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile()) continue;
|
||||
|
||||
const relPath = path.relative(resolvedRoot, fullPath).split(path.sep).join("/");
|
||||
const bytes = await fs.readFile(fullPath);
|
||||
files.push({ relPath, bytes });
|
||||
}
|
||||
}
|
||||
|
||||
await walk(resolvedRoot);
|
||||
files.sort((left, right) => left.relPath.localeCompare(right.relPath));
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function validateSelfContainedRelease(root) {
|
||||
const files = await listReleaseFiles(root);
|
||||
for (const file of files.filter((entry) => path.posix.basename(entry.relPath) === "package.json")) {
|
||||
const packageDir = path.resolve(root, fromPosixRel(path.posix.dirname(file.relPath)));
|
||||
const packageJson = JSON.parse(file.bytes.toString("utf8"));
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies || typeof dependencies !== "object") continue;
|
||||
|
||||
for (const [name, spec] of Object.entries(dependencies)) {
|
||||
if (typeof spec !== "string" || !spec.startsWith("file:")) continue;
|
||||
const targetDir = path.resolve(packageDir, spec.slice(5));
|
||||
if (!isWithinRoot(root, targetDir)) {
|
||||
throw new Error(
|
||||
`Release target is not self-contained: ${file.relPath} depends on ${name} via ${spec}`,
|
||||
);
|
||||
}
|
||||
await fs.access(targetDir).catch(() => {
|
||||
throw new Error(`Missing local dependency for release: ${file.relPath} -> ${spec}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fromPosixRel(relPath) {
|
||||
return relPath === "." ? "." : relPath.split("/").join(path.sep);
|
||||
}
|
||||
|
||||
function isWithinRoot(root, target) {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const relative = path.relative(resolvedRoot, path.resolve(target));
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const PACKAGE_DEPENDENCY_SECTIONS = [
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"devDependencies",
|
||||
];
|
||||
|
||||
const SKIPPED_DIRS = new Set([".git", ".clawhub", ".clawdhub", "node_modules"]);
|
||||
const SKIPPED_FILES = new Set([".DS_Store"]);
|
||||
|
||||
export async function syncSharedSkillPackages(repoRoot, options = {}) {
|
||||
const root = path.resolve(repoRoot);
|
||||
const workspacePackages = await discoverWorkspacePackages(root);
|
||||
const targetConsumerDirs = normalizeTargetConsumerDirs(root, options.targets ?? []);
|
||||
const consumers = await discoverSkillScriptPackages(root, targetConsumerDirs);
|
||||
const runtime = options.install === false ? null : resolveBunRuntime();
|
||||
const managedPaths = new Set();
|
||||
const packageDirs = [];
|
||||
|
||||
for (const consumer of consumers) {
|
||||
const result = await syncConsumerPackage({
|
||||
consumer,
|
||||
root,
|
||||
workspacePackages,
|
||||
runtime,
|
||||
});
|
||||
if (!result) continue;
|
||||
|
||||
packageDirs.push(consumer.dir);
|
||||
for (const managedPath of result.managedPaths) {
|
||||
managedPaths.add(managedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packageDirs,
|
||||
managedPaths: [...managedPaths].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTargetConsumerDirs(repoRoot, targets) {
|
||||
if (!targets || targets.length === 0) return null;
|
||||
|
||||
const consumerDirs = new Set();
|
||||
for (const target of targets) {
|
||||
if (!target) continue;
|
||||
|
||||
const resolvedTarget = path.resolve(repoRoot, target);
|
||||
if (path.basename(resolvedTarget) === "scripts") {
|
||||
consumerDirs.add(resolvedTarget);
|
||||
continue;
|
||||
}
|
||||
|
||||
consumerDirs.add(path.join(resolvedTarget, "scripts"));
|
||||
}
|
||||
|
||||
return consumerDirs;
|
||||
}
|
||||
|
||||
export function ensureManagedPathsClean(repoRoot, managedPaths) {
|
||||
if (managedPaths.length === 0) return;
|
||||
|
||||
const result = spawnSync("git", ["status", "--porcelain", "--", ...managedPaths], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr.trim() || "Failed to inspect git status for managed paths");
|
||||
}
|
||||
|
||||
const output = result.stdout.trim();
|
||||
if (!output) return;
|
||||
|
||||
throw new Error(
|
||||
[
|
||||
"Shared skill package sync produced uncommitted managed changes.",
|
||||
"Review and commit these files before pushing:",
|
||||
output,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
async function syncConsumerPackage({ consumer, root, workspacePackages, runtime }) {
|
||||
const packageJsonPath = path.join(consumer.dir, "package.json");
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
||||
const localDeps = collectLocalDependencies(packageJson, workspacePackages);
|
||||
if (localDeps.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vendorRoot = path.join(consumer.dir, "vendor");
|
||||
await fs.rm(vendorRoot, { recursive: true, force: true });
|
||||
|
||||
for (const name of localDeps) {
|
||||
const sourceDir = workspacePackages.get(name);
|
||||
if (!sourceDir) continue;
|
||||
await syncPackageTree({
|
||||
sourceDir,
|
||||
targetDir: path.join(vendorRoot, name),
|
||||
workspacePackages,
|
||||
});
|
||||
}
|
||||
|
||||
rewriteLocalDependencySpecs(packageJson, localDeps);
|
||||
await writeJson(packageJsonPath, packageJson);
|
||||
|
||||
if (runtime) {
|
||||
runInstall(runtime, consumer.dir);
|
||||
}
|
||||
|
||||
const managedPaths = [
|
||||
path.relative(root, packageJsonPath).split(path.sep).join("/"),
|
||||
path.relative(root, path.join(consumer.dir, "bun.lock")).split(path.sep).join("/"),
|
||||
path.relative(root, vendorRoot).split(path.sep).join("/"),
|
||||
];
|
||||
|
||||
return { managedPaths };
|
||||
}
|
||||
|
||||
async function syncPackageTree({ sourceDir, targetDir, workspacePackages }) {
|
||||
await fs.rm(targetDir, { recursive: true, force: true });
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const sourcePackageJsonPath = path.join(sourceDir, "package.json");
|
||||
const packageJson = JSON.parse(await fs.readFile(sourcePackageJsonPath, "utf8"));
|
||||
const localDeps = collectLocalDependencies(packageJson, workspacePackages);
|
||||
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue;
|
||||
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectory(sourcePath, targetPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || entry.name === "package.json") continue;
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
}
|
||||
|
||||
for (const name of localDeps) {
|
||||
const nestedSourceDir = workspacePackages.get(name);
|
||||
if (!nestedSourceDir) continue;
|
||||
await syncPackageTree({
|
||||
sourceDir: nestedSourceDir,
|
||||
targetDir: path.join(targetDir, "vendor", name),
|
||||
workspacePackages,
|
||||
});
|
||||
}
|
||||
|
||||
rewriteLocalDependencySpecs(packageJson, localDeps);
|
||||
await writeJson(path.join(targetDir, "package.json"), packageJson);
|
||||
}
|
||||
|
||||
async function copyDirectory(sourceDir, targetDir) {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (SKIPPED_DIRS.has(entry.name) || SKIPPED_FILES.has(entry.name)) continue;
|
||||
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await copyDirectory(sourcePath, targetPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) continue;
|
||||
await fs.mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await fs.copyFile(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverWorkspacePackages(repoRoot) {
|
||||
const packagesRoot = path.join(repoRoot, "packages");
|
||||
const map = new Map();
|
||||
if (!existsSync(packagesRoot)) return map;
|
||||
|
||||
const entries = await fs.readdir(packagesRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const packageJsonPath = path.join(packagesRoot, entry.name, "package.json");
|
||||
if (!existsSync(packageJsonPath)) continue;
|
||||
|
||||
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"));
|
||||
if (!packageJson.name) continue;
|
||||
map.set(packageJson.name, path.join(packagesRoot, entry.name));
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
async function discoverSkillScriptPackages(repoRoot, targetConsumerDirs = null) {
|
||||
const skillsRoot = path.join(repoRoot, "skills");
|
||||
const consumers = [];
|
||||
const skillEntries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
||||
for (const entry of skillEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const scriptsDir = path.join(skillsRoot, entry.name, "scripts");
|
||||
if (targetConsumerDirs && !targetConsumerDirs.has(path.resolve(scriptsDir))) continue;
|
||||
const packageJsonPath = path.join(scriptsDir, "package.json");
|
||||
if (!existsSync(packageJsonPath)) continue;
|
||||
consumers.push({ dir: scriptsDir, packageJsonPath });
|
||||
}
|
||||
return consumers.sort((left, right) => left.dir.localeCompare(right.dir));
|
||||
}
|
||||
|
||||
function collectLocalDependencies(packageJson, workspacePackages) {
|
||||
const localDeps = [];
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies || typeof dependencies !== "object") continue;
|
||||
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
if (!workspacePackages.has(name)) continue;
|
||||
localDeps.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(localDeps)].sort();
|
||||
}
|
||||
|
||||
function rewriteLocalDependencySpecs(packageJson, localDeps) {
|
||||
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies || typeof dependencies !== "object") continue;
|
||||
|
||||
for (const name of localDeps) {
|
||||
if (!(name in dependencies)) continue;
|
||||
dependencies[name] = `file:./vendor/${name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJson(filePath, value) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function resolveBunRuntime() {
|
||||
if (commandExists("bun")) {
|
||||
return { command: "bun", args: [] };
|
||||
}
|
||||
if (commandExists("npx")) {
|
||||
return { command: "npx", args: ["-y", "bun"] };
|
||||
}
|
||||
throw new Error(
|
||||
"Neither bun nor npx is installed. Install bun with `brew install oven-sh/bun/bun` or `npm install -g bun`.",
|
||||
);
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
const result = spawnSync("sh", ["-lc", `command -v ${command}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function runInstall(runtime, cwd) {
|
||||
const result = spawnSync(runtime.command, [...runtime.args, "install"], {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Failed to refresh Bun dependencies in ${cwd}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { listReleaseFiles, validateSelfContainedRelease } from "./lib/release-files.mjs";
|
||||
|
||||
const DEFAULT_REGISTRY = "https://clawhub.ai";
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (!options.skillDir || !options.version) {
|
||||
throw new Error("--skill-dir and --version are required");
|
||||
}
|
||||
|
||||
const skillDir = path.resolve(options.skillDir);
|
||||
const skill = buildSkillEntry(skillDir, options.slug, options.displayName);
|
||||
const changelog = options.changelogFile
|
||||
? await fs.readFile(path.resolve(options.changelogFile), "utf8")
|
||||
: "";
|
||||
|
||||
await validateSelfContainedRelease(skillDir);
|
||||
const files = await listReleaseFiles(skillDir);
|
||||
if (files.length === 0) {
|
||||
throw new Error(`Skill directory is empty: ${skillDir}`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`Dry run: would publish ${skill.slug}@${options.version}`);
|
||||
console.log(`Skill: ${skillDir}`);
|
||||
console.log(`Files: ${files.length}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = await readClawhubConfig();
|
||||
const registry = (
|
||||
options.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 tags = options.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
await publishSkill({
|
||||
registry,
|
||||
token: config.token,
|
||||
skill,
|
||||
files,
|
||||
version: options.version,
|
||||
changelog,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
skillDir: "",
|
||||
version: "",
|
||||
changelogFile: "",
|
||||
registry: "",
|
||||
tags: "latest",
|
||||
dryRun: false,
|
||||
slug: "",
|
||||
displayName: "",
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--skill-dir") {
|
||||
options.skillDir = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--version") {
|
||||
options.version = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--changelog-file") {
|
||||
options.changelogFile = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--registry") {
|
||||
options.registry = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tags") {
|
||||
options.tags = argv[index + 1] ?? "latest";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--slug") {
|
||||
options.slug = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--display-name") {
|
||||
options.displayName = argv[index + 1] ?? "";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--dry-run") {
|
||||
const next = argv[index + 1];
|
||||
if (next && !next.startsWith("-")) {
|
||||
options.dryRun = parseBoolean(next);
|
||||
index += 1;
|
||||
} else {
|
||||
options.dryRun = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: publish-skill.mjs --skill-dir <dir> --version <semver> [options]
|
||||
|
||||
Options:
|
||||
--skill-dir <dir> Skill directory to publish
|
||||
--version <semver> Version to publish
|
||||
--changelog-file <file> Release notes file
|
||||
--registry <url> Override registry base URL
|
||||
--tags <tags> Comma-separated tags (default: latest)
|
||||
--slug <value> Override slug
|
||||
--display-name <value> Override display name
|
||||
--dry-run Print publish plan without network calls
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
function buildSkillEntry(folder, slugOverride, displayNameOverride) {
|
||||
const base = path.basename(folder);
|
||||
return {
|
||||
folder,
|
||||
slug: slugOverride || sanitizeSlug(base),
|
||||
displayName: displayNameOverride || titleCase(base),
|
||||
};
|
||||
}
|
||||
|
||||
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 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: mimeType(file.relPath) }), 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 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 };
|
||||
}
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(body?.message || `HTTP ${response.status}`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
const MIME_MAP = {
|
||||
".md": "text/markdown",
|
||||
".ts": "text/plain",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".json": "application/json",
|
||||
".yml": "text/yaml",
|
||||
".yaml": "text/yaml",
|
||||
".txt": "text/plain",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".xml": "text/xml",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
function mimeType(relPath) {
|
||||
const ext = path.extname(relPath).toLowerCase();
|
||||
return MIME_MAP[ext] || "text/plain";
|
||||
}
|
||||
|
||||
function parseBoolean(value) {
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SKILLS_DIR="${ROOT_DIR}/skills"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo "Error: node is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
set -- --all
|
||||
fi
|
||||
|
||||
exec node "${ROOT_DIR}/scripts/sync-clawhub.mjs" --root "${SKILLS_DIR}" "$@"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SRC="$REPO_ROOT/skills/baoyu-markdown-to-html/scripts/md/"
|
||||
DEST="$REPO_ROOT/skills/baoyu-post-to-wechat/scripts/md/"
|
||||
|
||||
echo "Syncing: $SRC → $DEST"
|
||||
|
||||
rsync -av --delete \
|
||||
--exclude 'node_modules/' \
|
||||
--exclude 'package-lock.json' \
|
||||
"$SRC" "$DEST"
|
||||
|
||||
echo "Installing dependencies..."
|
||||
cd "$DEST" && npm install
|
||||
|
||||
echo "Done."
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ensureManagedPathsClean,
|
||||
syncSharedSkillPackages,
|
||||
} from "./lib/shared-skill-packages.mjs";
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const repoRoot = path.resolve(options.repoRoot);
|
||||
const result = await syncSharedSkillPackages(repoRoot, {
|
||||
targets: options.targets,
|
||||
});
|
||||
|
||||
if (options.enforceClean) {
|
||||
ensureManagedPathsClean(repoRoot, result.managedPaths);
|
||||
}
|
||||
|
||||
console.log(`Synced shared workspace packages into ${result.packageDirs.length} skill script package(s).`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
repoRoot: process.cwd(),
|
||||
enforceClean: false,
|
||||
targets: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--repo-root") {
|
||||
options.repoRoot = argv[index + 1] ?? options.repoRoot;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--enforce-clean") {
|
||||
options.enforceClean = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--target") {
|
||||
options.targets.push(argv[index + 1] ?? "");
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "-h" || arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: sync-shared-skill-packages.mjs [options]
|
||||
|
||||
Options:
|
||||
--repo-root <dir> Repository root (default: current directory)
|
||||
--target <dir> Sync only one skill directory (can be repeated)
|
||||
--enforce-clean Fail if managed files change after sync
|
||||
-h, --help Show help`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
name: baoyu-article-illustrator
|
||||
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style two-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
|
||||
---
|
||||
|
||||
# Article Illustrator
|
||||
@@ -16,6 +20,8 @@ Analyze articles, identify illustration positions, generate images with Type ×
|
||||
|
||||
Combine freely: `--type infographic --style blueprint`
|
||||
|
||||
Or use presets: `--preset tech-explainer` → type + style in one flag. See [Style Presets](references/style-presets.md).
|
||||
|
||||
## Types
|
||||
|
||||
| Type | Best For |
|
||||
@@ -47,10 +53,20 @@ See [references/styles.md](references/styles.md) for Core Styles, full gallery,
|
||||
**1.5 Load Preferences (EXTEND.md) ⛔ BLOCKING**
|
||||
|
||||
```bash
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary |
|
||||
@@ -73,13 +89,13 @@ Full procedures: [references/workflow.md](references/workflow.md#step-2-setup--a
|
||||
|
||||
### Step 3: Confirm Settings ⚠️
|
||||
|
||||
**ONE AskUserQuestion, max 4 Qs. Q1-Q3 REQUIRED.**
|
||||
**ONE AskUserQuestion, max 4 Qs. Q1-Q2 REQUIRED. Q3 required unless preset chosen.**
|
||||
|
||||
| Q | Options |
|
||||
|---|---------|
|
||||
| **Q1: Type** | [Recommended], infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||
| **Q1: Preset or Type** | [Recommended preset], [alt preset], or manual: infographic, scene, flowchart, comparison, framework, timeline, mixed |
|
||||
| **Q2: Density** | minimal (1-2), balanced (3-5), per-section (Recommended), rich (6+) |
|
||||
| **Q3: Style** | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, Other |
|
||||
| **Q3: Style** | [Recommended], minimal-flat, sci-fi, hand-drawn, editorial, scene, poster, Other — **skip if preset chosen** |
|
||||
| Q4: Language | When article language ≠ EXTEND.md setting |
|
||||
|
||||
Full procedures: [references/workflow.md](references/workflow.md#step-3-confirm-settings-)
|
||||
@@ -102,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.**
|
||||
|
||||
**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)
|
||||
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)
|
||||
@@ -151,5 +169,6 @@ illustrations/{topic-slug}/
|
||||
| [references/workflow.md](references/workflow.md) | Detailed procedures |
|
||||
| [references/usage.md](references/usage.md) | Command syntax |
|
||||
| [references/styles.md](references/styles.md) | Style gallery |
|
||||
| [references/style-presets.md](references/style-presets.md) | Preset shortcuts (type + style) |
|
||||
| [references/prompt-construction.md](references/prompt-construction.md) | Prompt templates |
|
||||
| [references/config/first-time-setup.md](references/config/first-time-setup.md) | First-time setup |
|
||||
|
||||
@@ -236,6 +236,38 @@ STYLE: [style characteristics]
|
||||
ASPECT: 16:9
|
||||
```
|
||||
|
||||
### Screen-Print Style Override
|
||||
|
||||
When `style: screen-print`, replace standard style instructions with:
|
||||
|
||||
```
|
||||
Screen print / silkscreen poster art. Flat color blocks, NO gradients.
|
||||
COLORS: 2-5 colors maximum. [Choose from style palette or duotone pair]
|
||||
TEXTURE: Halftone dot patterns, slight color layer misregistration, paper grain
|
||||
COMPOSITION: Bold silhouettes, geometric framing, negative space as storytelling element
|
||||
FIGURES: Silhouettes only, no detailed faces, stencil-cut edges
|
||||
TYPOGRAPHY: Bold condensed sans-serif integrated into composition (not overlaid)
|
||||
```
|
||||
|
||||
**Scene + screen-print**:
|
||||
```
|
||||
Conceptual poster scene. Single symbolic focal point, NOT literal illustration.
|
||||
COLORS: Duotone pair (e.g., Burnt Orange #E8751A + Deep Teal #0A6E6E) on Off-Black #121212
|
||||
COMPOSITION: Centered silhouette or geometric frame, 60%+ negative space
|
||||
TEXTURE: Halftone dots, paper grain, slight print misregistration
|
||||
```
|
||||
|
||||
**Comparison + screen-print**:
|
||||
```
|
||||
Split poster composition. Each side dominated by one color from duotone pair.
|
||||
LEFT: [Color A] side with silhouette/icon for [Option A]
|
||||
RIGHT: [Color B] side with silhouette/icon for [Option B]
|
||||
DIVIDER: Geometric shape or negative space boundary
|
||||
TEXTURE: Halftone transitions between sides
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- Vague descriptions ("a nice image")
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Style Presets
|
||||
|
||||
`--preset X` expands to a type + style combination. Users can override either dimension.
|
||||
|
||||
## By Category
|
||||
|
||||
### Technical & Engineering
|
||||
|
||||
| --preset | Type | Style | Best For |
|
||||
|----------|------|-------|----------|
|
||||
| `tech-explainer` | `infographic` | `blueprint` | API docs, system metrics, technical deep-dives |
|
||||
| `system-design` | `framework` | `blueprint` | Architecture diagrams, system design |
|
||||
| `architecture` | `framework` | `vector-illustration` | Component relationships, module structure |
|
||||
| `science-paper` | `infographic` | `scientific` | Research findings, lab results, academic |
|
||||
|
||||
### Knowledge & Education
|
||||
|
||||
| --preset | Type | Style | Best For |
|
||||
|----------|------|-------|----------|
|
||||
| `knowledge-base` | `infographic` | `vector-illustration` | Concept explainers, tutorials, how-to |
|
||||
| `saas-guide` | `infographic` | `notion` | Product guides, SaaS docs, tool walkthroughs |
|
||||
| `tutorial` | `flowchart` | `vector-illustration` | Step-by-step tutorials, setup guides |
|
||||
| `process-flow` | `flowchart` | `notion` | Workflow documentation, onboarding flows |
|
||||
|
||||
### Data & Analysis
|
||||
|
||||
| --preset | Type | Style | Best For |
|
||||
|----------|------|-------|----------|
|
||||
| `data-report` | `infographic` | `editorial` | Data journalism, metrics reports, dashboards |
|
||||
| `versus` | `comparison` | `vector-illustration` | Tech comparisons, framework shootouts |
|
||||
| `business-compare` | `comparison` | `elegant` | Product evaluations, strategy options |
|
||||
|
||||
### Narrative & Creative
|
||||
|
||||
| --preset | Type | Style | Best For |
|
||||
|----------|------|-------|----------|
|
||||
| `storytelling` | `scene` | `warm` | Personal essays, reflections, growth stories |
|
||||
| `lifestyle` | `scene` | `watercolor` | Travel, wellness, lifestyle, creative |
|
||||
| `history` | `timeline` | `elegant` | Historical overviews, milestones |
|
||||
| `evolution` | `timeline` | `warm` | Progress narratives, growth journeys |
|
||||
|
||||
### Editorial & Opinion
|
||||
|
||||
| --preset | Type | Style | Best For |
|
||||
|----------|------|-------|----------|
|
||||
| `opinion-piece` | `scene` | `screen-print` | Op-eds, commentary, critical essays |
|
||||
| `editorial-poster` | `comparison` | `screen-print` | Debate, contrasting viewpoints |
|
||||
| `cinematic` | `scene` | `screen-print` | Dramatic narratives, cultural essays |
|
||||
|
||||
## Content Type → Preset Recommendations
|
||||
|
||||
Use this table during Step 3 to recommend presets based on Step 2 content analysis:
|
||||
|
||||
| Content Type (Step 2) | Primary Preset | Alternatives |
|
||||
|------------------------|----------------|--------------|
|
||||
| Technical | `tech-explainer` | `system-design`, `architecture` |
|
||||
| Tutorial | `tutorial` | `process-flow`, `knowledge-base` |
|
||||
| Methodology / Framework | `system-design` | `architecture`, `process-flow` |
|
||||
| Data / Metrics | `data-report` | `versus`, `tech-explainer` |
|
||||
| Comparison / Review | `versus` | `business-compare`, `editorial-poster` |
|
||||
| Narrative / Personal | `storytelling` | `lifestyle`, `evolution` |
|
||||
| Opinion / Editorial | `opinion-piece` | `cinematic`, `editorial-poster` |
|
||||
| Historical / Timeline | `history` | `evolution` |
|
||||
| Academic / Research | `science-paper` | `tech-explainer`, `data-report` |
|
||||
| SaaS / Product | `saas-guide` | `knowledge-base`, `process-flow` |
|
||||
|
||||
## Override Examples
|
||||
|
||||
- `--preset tech-explainer --style notion` = infographic type with notion style
|
||||
- `--preset storytelling --type timeline` = timeline type with warm style
|
||||
|
||||
Explicit `--type`/`--style` flags always override preset values.
|
||||
@@ -12,6 +12,7 @@ Simplified style tier for quick selection:
|
||||
| `hand-drawn` | sketch/warm | Relaxed, reflective, casual content |
|
||||
| `editorial` | editorial | Processes, data, journalism |
|
||||
| `scene` | warm/watercolor | Narratives, emotional, lifestyle |
|
||||
| `poster` | screen-print | Opinion, editorial, cultural, cinematic |
|
||||
|
||||
Use Core Styles for most cases. See full Style Gallery below for granular control.
|
||||
|
||||
@@ -40,6 +41,7 @@ Use Core Styles for most cases. See full Style Gallery below for granular contro
|
||||
| `playful` | Whimsical pastel doodles | Fun, casual, educational |
|
||||
| `retro` | 80s/90s neon geometric | 80s/90s nostalgic, bold |
|
||||
| `sketch` | Raw pencil notebook style | Brainstorming, creative exploration |
|
||||
| `screen-print` | Bold poster art, halftone textures, limited colors | Opinion, editorial, cultural, cinematic |
|
||||
| `sketch-notes` | Soft hand-drawn warm notes | Educational, warm notes |
|
||||
| `vintage` | Aged parchment historical | Historical, heritage |
|
||||
|
||||
@@ -47,14 +49,14 @@ Full specifications: `references/styles/<style>.md`
|
||||
|
||||
## Type × Style Compatibility Matrix
|
||||
|
||||
| | vector-illustration | notion | warm | minimal | blueprint | watercolor | elegant | editorial | scientific |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| infographic | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ |
|
||||
| scene | ✓ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
| flowchart | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ |
|
||||
| comparison | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| framework | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✓ | ✓✓ |
|
||||
| timeline | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| | vector-illustration | notion | warm | minimal | blueprint | watercolor | elegant | editorial | scientific | screen-print |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| infographic | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| scene | ✓ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✗ | ✓✓ |
|
||||
| flowchart | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✓ | ✓✓ | ✓ | ✗ |
|
||||
| comparison | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓ |
|
||||
| framework | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓✓ | ✗ | ✓✓ | ✓ | ✓✓ | ✓ |
|
||||
| timeline | ✓ | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ |
|
||||
|
||||
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
|
||||
@@ -83,6 +85,7 @@ Full specifications: `references/styles/<style>.md`
|
||||
| History, timeline, progress, evolution | timeline | elegant, warm |
|
||||
| Productivity, SaaS, tool, app, software | infographic | notion, vector-illustration |
|
||||
| Business, professional, strategy, corporate | framework | elegant |
|
||||
| Opinion, editorial, culture, philosophy, cinematic, dramatic, poster | scene | screen-print |
|
||||
| Biology, chemistry, medical, scientific | infographic | scientific |
|
||||
| Explainer, journalism, magazine, investigation | infographic | editorial |
|
||||
|
||||
@@ -172,3 +175,22 @@ Full specifications: `references/styles/<style>.md`
|
||||
- Organic flow
|
||||
- Personal journey feel
|
||||
- Growth narratives
|
||||
|
||||
### scene + screen-print
|
||||
- Bold silhouettes, symbolic compositions
|
||||
- 2-5 flat colors with halftone textures
|
||||
- Figure-ground inversion (negative space tells secondary story)
|
||||
- Vintage poster aesthetic, conceptual not literal
|
||||
- Great for opinion pieces and cultural commentary
|
||||
|
||||
### comparison + screen-print
|
||||
- Split duotone composition (one color per side)
|
||||
- Bold geometric dividers
|
||||
- Symbolic icons over detailed rendering
|
||||
- High contrast, immediate visual impact
|
||||
|
||||
### framework + screen-print
|
||||
- Geometric node representations with stencil-cut edges
|
||||
- Limited color coding (one color per concept level)
|
||||
- Clean silhouette-based iconography
|
||||
- Poster-style hierarchy with bold typography
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# screen-print
|
||||
|
||||
Bold poster art with limited colors, halftone textures, and symbolic storytelling
|
||||
|
||||
## Design Aesthetic
|
||||
|
||||
Screen print / silkscreen aesthetic inspired by Mondo limited-edition posters and vintage concert prints. Flat color blocks, halftone dot patterns, bold silhouettes, and deliberate print imperfections. Conceptual and symbolic rather than literal — one iconic image tells the whole story. Perfect for opinion pieces, cultural commentary, and editorial content.
|
||||
|
||||
## Background
|
||||
|
||||
- Color: Off-Black (#121212) or Warm Cream (#F5E6D0)
|
||||
- Texture: Paper grain with subtle halftone dot overlay
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex | Usage |
|
||||
|------|-------|-----|-------|
|
||||
| Background | Off-Black | #121212 | Dark compositions |
|
||||
| Background Alt | Warm Cream | #F5E6D0 | Light compositions |
|
||||
| Primary | Burnt Orange | #E8751A | Main accent |
|
||||
| Secondary | Deep Teal | #0A6E6E | Contrast accent |
|
||||
| Tertiary | Crimson | #C0392B | Bold emphasis |
|
||||
| Highlight | Amber | #F4A623 | Small accents |
|
||||
| Text | Cream White | #FAF3E0 | On dark backgrounds |
|
||||
|
||||
**Duotone Pairs** (choose ONE pair for high-impact compositions):
|
||||
|
||||
| Pair | Color A | Color B | Feel |
|
||||
|------|---------|---------|------|
|
||||
| Orange + Teal | #E8751A | #0A6E6E | Cinematic, action |
|
||||
| Red + Cream | #C0392B | #F5E6D0 | Bold, classic |
|
||||
| Blue + Gold | #1A3A5C | #D4A843 | Prestigious, premium |
|
||||
| Crimson + Navy | #DC143C | #0D1B2A | Dramatic, noir |
|
||||
|
||||
**Rule**: Use 2-5 colors maximum. Fewer colors = stronger impact.
|
||||
|
||||
## Visual Elements
|
||||
|
||||
- Bold silhouettes and symbolic shapes
|
||||
- Halftone dot patterns within color fills
|
||||
- Slight color layer misregistration (print offset effect)
|
||||
- Geometric framing (circles, arches, triangles)
|
||||
- Figure-ground inversion (negative space forms secondary image)
|
||||
- Stencil-cut edges, no outlines — shapes defined by color boundaries
|
||||
- Typography integrated as design element, not overlay
|
||||
- Vintage poster border treatments
|
||||
|
||||
## Style Rules
|
||||
|
||||
### Do
|
||||
|
||||
- Limit to 2-5 flat colors
|
||||
- Use bold silhouettes over detailed rendering
|
||||
- Let negative space tell part of the story
|
||||
- Add halftone texture for authenticity
|
||||
- Use geometric composition (centered, symmetrical)
|
||||
- Reference vintage decades (60s/70s/80s) for era feel
|
||||
|
||||
### Don't
|
||||
|
||||
- Use photorealistic rendering or gradients
|
||||
- Add complex facial details (silhouettes preferred)
|
||||
- Mix too many visual elements (one focal point)
|
||||
- Use modern digital aesthetic
|
||||
- Create busy or cluttered compositions
|
||||
- Use more than 5 colors
|
||||
|
||||
## Best For
|
||||
|
||||
Opinion/editorial articles, cultural commentary, philosophy and strategy, dramatic narratives, cinematic storytelling, music and entertainment, event announcements, bold branding content
|
||||
@@ -29,6 +29,7 @@
|
||||
|--------|-------------|
|
||||
| `--type <name>` | Illustration type (see Type Gallery in SKILL.md) |
|
||||
| `--style <name>` | Visual style (see references/styles.md) |
|
||||
| `--preset <name>` | Shorthand for type + style combo (see [references/style-presets.md](references/style-presets.md)) |
|
||||
| `--density <level>` | Image count: minimal / balanced / rich |
|
||||
|
||||
## Input Modes
|
||||
@@ -55,12 +56,27 @@ Configure in EXTEND.md: `default_output_dir: illustrations-subdir`
|
||||
/baoyu-article-illustrator api-design.md --type infographic --style blueprint
|
||||
```
|
||||
|
||||
**Same thing with preset**:
|
||||
```bash
|
||||
/baoyu-article-illustrator api-design.md --preset tech-explainer
|
||||
```
|
||||
|
||||
**Personal story**:
|
||||
```bash
|
||||
/baoyu-article-illustrator journey.md --type scene --style warm
|
||||
/baoyu-article-illustrator journey.md --preset storytelling
|
||||
```
|
||||
|
||||
**Tutorial with steps**:
|
||||
```bash
|
||||
/baoyu-article-illustrator how-to-deploy.md --type flowchart --density rich
|
||||
/baoyu-article-illustrator how-to-deploy.md --preset tutorial --density rich
|
||||
```
|
||||
|
||||
**Opinion article with poster style**:
|
||||
```bash
|
||||
/baoyu-article-illustrator opinion.md --preset opinion-piece
|
||||
```
|
||||
|
||||
**Preset with override**:
|
||||
```bash
|
||||
/baoyu-article-illustrator article.md --preset tech-explainer --style notion
|
||||
```
|
||||
|
||||
@@ -86,10 +86,20 @@ Check preferences and existing state, then ask ALL needed questions in ONE AskUs
|
||||
**CRITICAL**: If EXTEND.md not found, MUST complete first-time setup before ANY other questions or steps. Do NOT proceed to reference images, do NOT ask about content, do NOT ask about type/style — ONLY complete the preferences setup first.
|
||||
|
||||
```bash
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Read, parse, display summary → Continue |
|
||||
@@ -158,9 +168,16 @@ For each reference image:
|
||||
|
||||
**Do NOT skip.** Use ONE AskUserQuestion call with max 4 questions. **Q1, Q2, Q3 are ALL REQUIRED.**
|
||||
|
||||
### Q1: Illustration Type ⚠️ REQUIRED
|
||||
- [Recommended based on analysis] (Recommended)
|
||||
- infographic / scene / flowchart / comparison / framework / timeline / mixed
|
||||
### Q1: Preset or Type ⚠️ REQUIRED
|
||||
|
||||
Based on Step 2 content analysis, recommend a preset first (sets both type & style). Look up [style-presets.md](style-presets.md) "Content Type → Preset Recommendations" table.
|
||||
|
||||
- [Recommended preset] — [brief: type + style + why] (Recommended)
|
||||
- [Alternative preset] — [brief]
|
||||
- Or choose type manually: infographic / scene / flowchart / comparison / framework / timeline / mixed
|
||||
|
||||
**If user picks a preset → skip Q3** (type & style both resolved).
|
||||
**If user picks a type → Q3 is REQUIRED.**
|
||||
|
||||
### Q2: Density ⚠️ REQUIRED - DO NOT SKIP
|
||||
- minimal (1-2) - Core concepts only
|
||||
@@ -168,7 +185,7 @@ For each reference image:
|
||||
- per-section - At least 1 per section/chapter (Recommended)
|
||||
- rich (6+) - Comprehensive coverage
|
||||
|
||||
### Q3: Style ⚠️ REQUIRED (ALWAYS ask, even with preferred_style in EXTEND.md)
|
||||
### Q3: Style ⚠️ REQUIRED (skip if preset chosen in Q1)
|
||||
|
||||
If EXTEND.md has `preferred_style`:
|
||||
- [Custom style name + brief description] (Recommended)
|
||||
@@ -184,13 +201,14 @@ If no `preferred_style` (present Core Styles first):
|
||||
|
||||
**Core Styles** (simplified selection):
|
||||
|
||||
| Core Style | Best For |
|
||||
|------------|----------|
|
||||
| `minimal-flat` | General, knowledge sharing, SaaS |
|
||||
| `sci-fi` | AI, frontier tech, system design |
|
||||
| `hand-drawn` | Relaxed, reflective, casual |
|
||||
| `editorial` | Processes, data, journalism |
|
||||
| `scene` | Narratives, emotional, lifestyle |
|
||||
| Core Style | Maps To | Best For |
|
||||
|------------|---------|----------|
|
||||
| `minimal-flat` | notion | General, knowledge sharing, SaaS |
|
||||
| `sci-fi` | blueprint | AI, frontier tech, system design |
|
||||
| `hand-drawn` | sketch/warm | Relaxed, reflective, casual |
|
||||
| `editorial` | editorial | Processes, data, journalism |
|
||||
| `scene` | warm/watercolor | Narratives, emotional, lifestyle |
|
||||
| `poster` | screen-print | Opinion, editorial, cultural, cinematic |
|
||||
|
||||
Style selection based on Type × Style compatibility matrix (styles.md).
|
||||
Full specs: `styles/<style>.md`
|
||||
@@ -297,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`.
|
||||
|
||||
**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**:
|
||||
- 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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+22
-17
@@ -1,6 +1,14 @@
|
||||
---
|
||||
name: baoyu-comic
|
||||
description: Knowledge comic creator supporting multiple art styles and tones. Creates original educational comics with detailed panel layouts and sequential image generation. Use when user asks to create "知识漫画", "教育漫画", "biography comic", "tutorial comic", or "Logicomix-style comic".
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-comic
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Knowledge Comic Creator
|
||||
@@ -104,9 +112,10 @@ Details: [references/auto-selection.md](references/auto-selection.md)
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
1. Determine this SKILL.md file's directory path as `{baseDir}`
|
||||
2. Script path = `{baseDir}/scripts/<script-name>.ts`
|
||||
3. Replace all `{baseDir}` in this document with the actual path
|
||||
4. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
@@ -207,12 +216,11 @@ Analyze → [Check Existing?] → [Confirm: Style + Reviews] → Storyboard →
|
||||
|
||||
**7.1 Generate character sheet first**:
|
||||
- **Backup rule**: If `characters/characters.png` exists, rename to `characters/characters-backup-YYYYMMDD-HHMMSS.png`
|
||||
```bash
|
||||
# Use Reference Sheet Prompt from characters/characters.md
|
||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||
--promptfiles characters/characters.md \
|
||||
--image characters/characters.png --ar 4:3
|
||||
```
|
||||
- Invoke an installed image generation skill such as `baoyu-image-gen`
|
||||
- Read that skill's `SKILL.md` and follow its documented interface rather than calling its scripts directly
|
||||
- Use `characters/characters.md` as the prompt-file input
|
||||
- Save output to `characters/characters.png`
|
||||
- Use aspect ratio `4:3`
|
||||
|
||||
**Compress character sheet** (recommended):
|
||||
Compress to reduce token usage when used as reference image:
|
||||
@@ -230,14 +238,11 @@ Compress to reduce token usage when used as reference image:
|
||||
**Backup rules for page generation**:
|
||||
- If prompt file exists: rename to `prompts/NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.md`
|
||||
- If image file exists: rename to `NN-{cover|page}-[slug]-backup-YYYYMMDD-HHMMSS.png`
|
||||
|
||||
```bash
|
||||
# Example: ALWAYS include --ref for consistency
|
||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||
--promptfiles prompts/01-page-xxx.md \
|
||||
--image 01-page-xxx.png --ar 3:4 \
|
||||
--ref characters/characters.png
|
||||
```
|
||||
- Invoke the installed image generation skill for each page
|
||||
- Use `prompts/01-page-xxx.md` as the prompt-file input
|
||||
- Save output to `01-page-xxx.png`
|
||||
- Use aspect ratio `3:4`
|
||||
- If the chosen skill supports reference images, pass `characters/characters.png` as `--ref`
|
||||
|
||||
**Full workflow details**: [references/workflow.md](references/workflow.md)
|
||||
|
||||
|
||||
@@ -34,16 +34,23 @@ Input → Preferences → Analyze → [Check Existing?] → [Confirm 1: Style +
|
||||
|
||||
### 1.1 Load Preferences (EXTEND.md)
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-comic/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
| Path | Location |
|
||||
|------|----------|
|
||||
| `.baoyu-skills/baoyu-comic/EXTEND.md` | Project directory |
|
||||
@@ -429,14 +436,12 @@ With confirmed prompts from Step 5/6:
|
||||
|
||||
**Strategy A: Using `--ref` parameter** (e.g., baoyu-image-gen)
|
||||
|
||||
```bash
|
||||
# Each page generation MUST include --ref
|
||||
npx -y bun ${SKILL_DIR}/../baoyu-image-gen/scripts/main.ts \
|
||||
--promptfiles prompts/01-page-xxx.md \
|
||||
--image 01-page-xxx.png \
|
||||
--ar 3:4 \
|
||||
--ref characters/characters.png
|
||||
```
|
||||
- Read the chosen image generation skill's `SKILL.md`
|
||||
- Invoke that installed skill via its documented interface, not by calling its scripts directly
|
||||
- For every page, use `prompts/01-page-xxx.md` as the prompt-file input
|
||||
- Save output to `01-page-xxx.png`
|
||||
- Use aspect ratio `3:4`
|
||||
- Pass `characters/characters.png` as `--ref` on every page generation
|
||||
|
||||
**Strategy B: Embedding character descriptions in prompt**
|
||||
|
||||
@@ -474,7 +479,7 @@ If image generation skill supports `--sessionId`:
|
||||
After all images generated:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/merge-to-pdf.ts <comic-dir>
|
||||
${BUN_X} {baseDir}/scripts/merge-to-pdf.ts <comic-dir>
|
||||
```
|
||||
|
||||
Creates `{topic-slug}.pdf` with all pages as full-page images.
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
---
|
||||
name: baoyu-compress-image
|
||||
description: Compresses images to WebP (default) or PNG with automatic tool selection. Use when user asks to "compress image", "optimize image", "convert to webp", or reduce image file size.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-compress-image
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Image Compressor
|
||||
@@ -9,7 +17,7 @@ Compresses images using best available tool (sips → cwebp → ImageMagick →
|
||||
|
||||
## Script Directory
|
||||
|
||||
Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's directory path.
|
||||
Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values.
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
@@ -17,16 +25,23 @@ Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-compress-image/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
┌────────────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├────────────────────────────────────────────────────────┼───────────────────┤
|
||||
@@ -48,7 +63,7 @@ test -f "$HOME/.baoyu-skills/baoyu-compress-image/EXTEND.md" && echo "user"
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <input> [options]
|
||||
${BUN_X} {baseDir}/scripts/main.ts <input> [options]
|
||||
```
|
||||
|
||||
## Options
|
||||
@@ -67,16 +82,16 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts <input> [options]
|
||||
|
||||
```bash
|
||||
# Single file → WebP (replaces original)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts image.png
|
||||
|
||||
# Keep PNG format
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -f png --keep
|
||||
${BUN_X} {baseDir}/scripts/main.ts image.png -f png --keep
|
||||
|
||||
# Directory recursive
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r -q 75
|
||||
${BUN_X} {baseDir}/scripts/main.ts ./images/ -r -q 75
|
||||
|
||||
# JSON output
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json
|
||||
${BUN_X} {baseDir}/scripts/main.ts image.png --json
|
||||
```
|
||||
|
||||
**Output**:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
name: baoyu-cover-image
|
||||
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 9 color palettes and 6 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
|
||||
description: Generates article cover images with 5 dimensions (type, palette, rendering, text, mood) combining 10 color palettes and 7 rendering styles. Supports cinematic (2.35:1), widescreen (16:9), and square (1:1) aspects. Use when user asks to "generate cover image", "create article cover", or "make cover".
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-cover-image
|
||||
---
|
||||
|
||||
# Cover Image Generator
|
||||
@@ -35,8 +39,8 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--type <name>` | hero, conceptual, typography, metaphor, scene, minimal |
|
||||
| `--palette <name>` | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro |
|
||||
| `--rendering <name>` | flat-vector, hand-drawn, painterly, digital, pixel, chalk |
|
||||
| `--palette <name>` | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro, duotone |
|
||||
| `--rendering <name>` | flat-vector, hand-drawn, painterly, digital, pixel, chalk, screen-print |
|
||||
| `--style <name>` | Preset shorthand (see [Style Presets](references/style-presets.md)) |
|
||||
| `--text <level>` | none, title-only, title-subtitle, text-rich |
|
||||
| `--mood <level>` | subtle, balanced, bold |
|
||||
@@ -52,8 +56,8 @@ Generate elegant cover images for articles with 5-dimensional customization.
|
||||
| Dimension | Values | Default |
|
||||
|-----------|--------|---------|
|
||||
| **Type** | hero, conceptual, typography, metaphor, scene, minimal | auto |
|
||||
| **Palette** | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro | auto |
|
||||
| **Rendering** | flat-vector, hand-drawn, painterly, digital, pixel, chalk | auto |
|
||||
| **Palette** | warm, elegant, cool, dark, earth, vivid, pastel, mono, retro, duotone | auto |
|
||||
| **Rendering** | flat-vector, hand-drawn, painterly, digital, pixel, chalk, screen-print | auto |
|
||||
| **Text** | none, title-only, title-subtitle, text-rich | title-only |
|
||||
| **Mood** | subtle, balanced, bold | balanced |
|
||||
| **Font** | clean, handwritten, serif, display | clean |
|
||||
@@ -65,10 +69,10 @@ Auto-selection rules: [references/auto-selection.md](references/auto-selection.m
|
||||
**Types**: hero, conceptual, typography, metaphor, scene, minimal
|
||||
→ Details: [references/types.md](references/types.md)
|
||||
|
||||
**Palettes**: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro
|
||||
**Palettes**: warm, elegant, cool, dark, earth, vivid, pastel, mono, retro, duotone
|
||||
→ Details: [references/palettes/](references/palettes/)
|
||||
|
||||
**Renderings**: flat-vector, hand-drawn, painterly, digital, pixel, chalk
|
||||
**Renderings**: flat-vector, hand-drawn, painterly, digital, pixel, chalk, screen-print
|
||||
→ Details: [references/renderings/](references/renderings/)
|
||||
|
||||
**Text Levels**: none (pure visual) | title-only (default) | title-subtitle | text-rich (with tags)
|
||||
@@ -128,10 +132,20 @@ Analyze + Save Refs → [Output Dir] → [Confirm: 6 Dimensions] → Prompt →
|
||||
|
||||
Check EXTEND.md existence (priority: project → user):
|
||||
```bash
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Load, display summary → Continue |
|
||||
|
||||
@@ -26,6 +26,7 @@ When a dimension is omitted, select based on content signals.
|
||||
| Fantasy, children, gentle, creative, whimsical | `pastel` |
|
||||
| Zen, focus, essential, pure, simple | `mono` |
|
||||
| History, vintage, retro, classic, exploration | `retro` |
|
||||
| Movie poster, album cover, concert, cinematic, dramatic, two-color | `duotone` |
|
||||
|
||||
## Auto Rendering Selection
|
||||
|
||||
@@ -37,6 +38,7 @@ When a dimension is omitted, select based on content signals.
|
||||
| Data, dashboard, SaaS, corporate, polished | `digital` |
|
||||
| Gaming, retro, 8-bit, nostalgic | `pixel` |
|
||||
| Education, tutorial, classroom, teaching | `chalk` |
|
||||
| Poster, movie, album, concert, silhouette, mondo, limited-edition | `screen-print` |
|
||||
|
||||
## Auto Text Selection
|
||||
|
||||
|
||||
@@ -4,28 +4,29 @@
|
||||
|
||||
## Palette × Rendering
|
||||
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| elegant | ✓ | ✓✓ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| cool | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ |
|
||||
| dark | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓✓ |
|
||||
| earth | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✗ |
|
||||
| vivid | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ |
|
||||
| pastel | ✓✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✗ |
|
||||
| mono | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ |
|
||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✗ |
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk | screen-print |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| warm | ✓✓ | ✓✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| elegant | ✓ | ✓✓ | ✓ | ✓✓ | ✗ | ✗ | ✓ |
|
||||
| cool | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| dark | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓✓ |
|
||||
| earth | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✗ | ✓ |
|
||||
| vivid | ✓✓ | ✓ | ✓ | ✓ | ✓✓ | ✓ | ✓✓ |
|
||||
| pastel | ✓✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✗ | ✗ |
|
||||
| mono | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✓✓ |
|
||||
| retro | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| duotone | ✓ | ✗ | ✗ | ✓ | ✗ | ✗ | ✓✓ |
|
||||
|
||||
## Type × Rendering
|
||||
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| hero | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ |
|
||||
| conceptual | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ |
|
||||
| typography | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| metaphor | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓ |
|
||||
| scene | ✗ | ✓ | ✓✓ | ✓ | ✓ | ✗ |
|
||||
| minimal | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk | screen-print |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| hero | ✓ | ✓✓ | ✓✓ | ✓✓ | ✓ | ✓ | ✓✓ |
|
||||
| conceptual | ✓✓ | ✓ | ✗ | ✓✓ | ✓ | ✓ | ✓ |
|
||||
| typography | ✓✓ | ✓ | ✓ | ✓✓ | ✓ | ✓ | ✓✓ |
|
||||
| metaphor | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓ | ✓✓ |
|
||||
| scene | ✗ | ✓ | ✓✓ | ✓ | ✓ | ✗ | ✓ |
|
||||
| minimal | ✓✓ | ✓ | ✓ | ✓✓ | ✗ | ✗ | ✓✓ |
|
||||
|
||||
## Type × Text
|
||||
|
||||
@@ -51,9 +52,9 @@
|
||||
|
||||
## Font × Rendering
|
||||
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| | flat-vector | hand-drawn | painterly | digital | pixel | chalk | screen-print |
|
||||
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ | ✓ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✗ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ | ✓ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ |
|
||||
|
||||
@@ -120,12 +120,12 @@ Bold, decorative typography for maximum impact.
|
||||
|
||||
## Rendering Compatibility
|
||||
|
||||
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk |
|
||||
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ |
|
||||
| Font × Rendering | flat-vector | hand-drawn | painterly | digital | pixel | chalk | screen-print |
|
||||
|------------------|:-----------:|:----------:|:---------:|:-------:|:-----:|:-----:|:------------:|
|
||||
| clean | ✓✓ | ✗ | ✗ | ✓✓ | ✓ | ✗ | ✓ |
|
||||
| handwritten | ✓ | ✓✓ | ✓✓ | ✓ | ✗ | ✓✓ | ✗ |
|
||||
| serif | ✓ | ✗ | ✓ | ✓✓ | ✗ | ✗ | ✓ |
|
||||
| display | ✓✓ | ✓ | ✓ | ✓✓ | ✓✓ | ✓ | ✓✓ |
|
||||
|
||||
✓✓ = highly recommended | ✓ = compatible | ✗ = not recommended
|
||||
|
||||
@@ -148,6 +148,7 @@ Font style adapts to palette characteristics:
|
||||
| Cool (cool, mono, elegant) | Perfect match | Contrast | Classic pairing | Bold statement |
|
||||
| Dark (dark, vivid) | High contrast | Glow effects | Dramatic | Maximum impact |
|
||||
| Vintage (retro) | Modern contrast | Nostalgic fit | Period-appropriate | Retro headlines |
|
||||
| Duotone (duotone) | Sharp contrast | Not recommended | Dramatic pairing | Cinematic impact |
|
||||
|
||||
## Auto Selection
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ Mood modifies the base palette characteristics:
|
||||
| Cool palettes (cool, mono, elegant) | Lighter lines, muted colors | Standard colors | Stronger contrast, sharper definition |
|
||||
| Dark palettes (dark, vivid) | Reduced contrast, softer glow | Standard colors | Maximum impact, vivid saturation |
|
||||
| Vintage palettes (retro) | More faded, sepia-heavy | Standard colors | Bolder retro contrasts |
|
||||
| Duotone palettes (duotone) | Softer contrast between pair | Standard two-color split | Maximum contrast, stark separation |
|
||||
|
||||
## Rendering Interaction
|
||||
|
||||
@@ -125,6 +126,7 @@ Mood adjusts rendering characteristics:
|
||||
| digital | Reduced shadows, lower contrast | Standard rendering | Stronger shadows, sharper edges |
|
||||
| pixel | Fewer colors, simpler shapes | Standard palette | More colors, denser pixel detail |
|
||||
| chalk | Lighter chalk, more board showing | Standard chalk | Heavy chalk, vivid colors, dense marks |
|
||||
| screen-print | Fewer colors (2), lighter halftone | Standard 3-4 colors, medium halftone | More colors (4-5), dense halftone, stronger misregistration |
|
||||
|
||||
## Auto Selection
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# duotone
|
||||
|
||||
Dramatic, cinematic, two-color high contrast
|
||||
|
||||
## Color Palette
|
||||
|
||||
| Role | Color | Hex |
|
||||
|------|-------|-----|
|
||||
| Primary 1 | Burnt Orange | #E8751A |
|
||||
| Primary 2 | Deep Teal | #0A6E6E |
|
||||
| Background | Off-Black | #121212 |
|
||||
| Background Alt | Dark Charcoal | #1E1E1E |
|
||||
| Accent 1 | Warm Cream | #F5E6D0 |
|
||||
| Accent 2 | Amber Highlight | #F4A623 |
|
||||
|
||||
## Duotone Pair Options
|
||||
|
||||
Choose ONE pair based on content mood. The two colors dominate the entire image:
|
||||
|
||||
| Pair | Color A | Color B | Feel |
|
||||
|------|---------|---------|------|
|
||||
| Orange + Teal | #E8751A | #0A6E6E | Cinematic, action |
|
||||
| Red + Cream | #C0392B | #F5E6D0 | Bold, classic |
|
||||
| Blue + Gold | #1A3A5C | #D4A843 | Prestigious, premium |
|
||||
| Purple + Green | #6B3FA0 | #2ECC71 | Futuristic, contrast |
|
||||
| Magenta + Cyan | #C2185B | #00BCD4 | Vibrant, pop |
|
||||
| Crimson + Navy | #DC143C | #0D1B2A | Dramatic, noir |
|
||||
|
||||
## Decorative Hints
|
||||
|
||||
- Stark two-color separation across entire composition
|
||||
- Halftone transitions between the two colors
|
||||
- Silhouettes in one color against the other
|
||||
- Minimal use of third color (only for small highlights)
|
||||
- High contrast figure-ground relationships
|
||||
|
||||
## Best For
|
||||
|
||||
Movie posters, album covers, concert prints, dramatic announcements, cinematic content, bold branding, editorial covers, artistic campaigns
|
||||
@@ -0,0 +1,44 @@
|
||||
# screen-print
|
||||
|
||||
Bold, limited-color poster art with print texture
|
||||
|
||||
## Core Characteristics
|
||||
|
||||
Screen print / silkscreen aesthetic with flat color blocks, halftone textures, and deliberate print imperfections. Think Mondo limited-edition posters, vintage concert prints, and alternative movie poster art.
|
||||
|
||||
## Lines
|
||||
|
||||
- Clean, sharp edges between color blocks
|
||||
- No outlines — shapes defined by color boundaries
|
||||
- Stencil-cut quality, bold silhouettes
|
||||
- Geometric shapes with precise registration
|
||||
|
||||
## Texture
|
||||
|
||||
- Halftone dot patterns within color fills
|
||||
- Slight color layer misregistration (offset between print layers)
|
||||
- Paper grain texture beneath colors
|
||||
- Risograph / screen print imperfections (ink spread, dot gain)
|
||||
|
||||
## Depth
|
||||
|
||||
- Flat color planes layered front to back
|
||||
- Depth through silhouette overlap and color layering
|
||||
- No gradients — tonal variation via halftone density
|
||||
- Negative space as active compositional element
|
||||
|
||||
## Element Vocabulary
|
||||
|
||||
- Bold silhouettes and symbolic shapes
|
||||
- Geometric framing (circles, arches, triangles)
|
||||
- Figure-ground inversion (negative space forms secondary image)
|
||||
- Limited icon vocabulary: key props, symbolic objects
|
||||
- Typography integrated as design element, not overlay
|
||||
- Vintage poster border treatments
|
||||
|
||||
## Typography Approach
|
||||
|
||||
- Bold condensed sans-serif or hand-drawn lettering
|
||||
- Art Deco influences, vintage poster typography
|
||||
- Typography as integral part of composition (not separate layer)
|
||||
- Strong readability through high contrast with background
|
||||
@@ -24,6 +24,11 @@
|
||||
| `warm` | `warm` | `hand-drawn` |
|
||||
| `warm-flat` | `warm` | `flat-vector` |
|
||||
| `watercolor` | `earth` | `painterly` |
|
||||
| `poster-art` | `retro` | `screen-print` |
|
||||
| `mondo` | `mono` | `screen-print` |
|
||||
| `art-deco` | `elegant` | `screen-print` |
|
||||
| `propaganda` | `vivid` | `screen-print` |
|
||||
| `cinematic` | `duotone` | `screen-print` |
|
||||
|
||||
## Override Examples
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
---
|
||||
name: baoyu-danger-gemini-web
|
||||
description: Generates images and text via reverse-engineered Gemini Web API. Supports text generation, image generation from prompts, reference images for vision input, and multi-turn conversations. Use when other skills need image generation backend, or when user requests "generate image with Gemini", "Gemini text generation", or needs vision-capable AI generation.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-danger-gemini-web
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Gemini Web Client
|
||||
@@ -12,9 +20,10 @@ Text/image generation via Gemini Web API. Supports reference images and multi-tu
|
||||
**Important**: All scripts are located in the `scripts/` subdirectory of this skill.
|
||||
|
||||
**Agent Execution Instructions**:
|
||||
1. Determine this SKILL.md file's directory path as `SKILL_DIR`
|
||||
2. Script path = `${SKILL_DIR}/scripts/<script-name>.ts`
|
||||
3. Replace all `${SKILL_DIR}` in this document with the actual path
|
||||
1. Determine this SKILL.md file's directory path as `{baseDir}`
|
||||
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
|
||||
4. Replace all `{baseDir}` and `${BUN_X}` in this document with actual values
|
||||
|
||||
**Script Reference**:
|
||||
| Script | Purpose |
|
||||
@@ -43,16 +52,23 @@ Before first use, verify user consent for reverse-engineered API usage.
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-danger-gemini-web/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
┌──────────────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├──────────────────────────────────────────────────────────┼───────────────────┤
|
||||
@@ -75,23 +91,23 @@ test -f "$HOME/.baoyu-skills/baoyu-danger-gemini-web/EXTEND.md" && echo "user"
|
||||
|
||||
```bash
|
||||
# Text generation
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Your prompt"
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
|
||||
${BUN_X} {baseDir}/scripts/main.ts "Your prompt"
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Your prompt" --model gemini-3-flash
|
||||
|
||||
# Image generation
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cute cat" --image cat.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# Vision input (reference images)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Describe this" --reference image.png
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Create variation" --reference a.png --image out.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Describe this" --reference image.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Create variation" --reference a.png --image out.png
|
||||
|
||||
# Multi-turn conversation
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Remember: 42" --sessionId session-abc
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "What number?" --sessionId session-abc
|
||||
${BUN_X} {baseDir}/scripts/main.ts "Remember: 42" --sessionId session-abc
|
||||
${BUN_X} {baseDir}/scripts/main.ts "What number?" --sessionId session-abc
|
||||
|
||||
# JSON output
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts "Hello" --json
|
||||
${BUN_X} {baseDir}/scripts/main.ts "Hello" --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-danger-gemini-web-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
+92
-194
@@ -1,181 +1,76 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import net from 'node:net';
|
||||
import process from 'node:process';
|
||||
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome as launchChromeBase,
|
||||
openPageSession,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from 'baoyu-chrome-cdp';
|
||||
|
||||
import { Endpoint, Headers } from '../constants.js';
|
||||
import { logger } from './logger.js';
|
||||
import { cookie_header, fetch_with_timeout, sleep } from './http.js';
|
||||
import { cookie_header, fetch_with_timeout } from './http.js';
|
||||
import { read_cookie_file, type CookieMap, write_cookie_file } from './cookie-file.js';
|
||||
import { resolveGeminiWebChromeProfileDir, resolveGeminiWebCookiePath } from './paths.js';
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
const GEMINI_APP_URL = 'https://gemini.google.com/app';
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener('close', () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error('CDP connection closed.'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('CDP connection timeout.')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('CDP connection failed.'));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, opts?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
darwin: [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
],
|
||||
win32: [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
],
|
||||
default: [
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
],
|
||||
};
|
||||
|
||||
async function get_free_port(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on('error', reject);
|
||||
srv.listen(0, '127.0.0.1', () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
srv.close(() => reject(new Error('Unable to allocate a free TCP port.')));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
return await getFreePort('GEMINI_WEB_DEBUG_PORT');
|
||||
}
|
||||
|
||||
function find_chrome_executable(): string | null {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
candidates.push(
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
||||
'/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
);
|
||||
break;
|
||||
case 'win32':
|
||||
candidates.push(
|
||||
'C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe',
|
||||
'C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
'C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/snap/bin/chromium',
|
||||
'/usr/bin/microsoft-edge',
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES_FULL,
|
||||
envNames: ['GEMINI_WEB_CHROME_PATH'],
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
async function wait_for_chrome_debug_port(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetch_with_timeout(`http://127.0.0.1:${port}/json/version`, { timeout_ms: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error('Chrome debug port not ready');
|
||||
async function find_existing_chrome_debug_port(profileDir: string): Promise<number | null> {
|
||||
return await findExistingChromeDebugPort({ profileDir });
|
||||
}
|
||||
|
||||
async function launch_chrome(profileDir: string, port: number): Promise<ChildProcess> {
|
||||
const chrome = find_chrome_executable();
|
||||
if (!chrome) throw new Error('Chrome executable not found.');
|
||||
async function launch_chrome(profileDir: string, port: number) {
|
||||
const chromePath = find_chrome_executable();
|
||||
if (!chromePath) throw new Error('Chrome executable not found.');
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-popup-blocking',
|
||||
'https://gemini.google.com/app',
|
||||
];
|
||||
|
||||
return spawn(chrome, args, { stdio: 'ignore' });
|
||||
return await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir,
|
||||
port,
|
||||
url: GEMINI_APP_URL,
|
||||
extraArgs: ['--disable-popup-blocking'],
|
||||
});
|
||||
}
|
||||
|
||||
async function is_gemini_session_ready(cookies: CookieMap, verbose: boolean): Promise<boolean> {
|
||||
@@ -207,27 +102,33 @@ async function fetch_google_cookies_via_cdp(
|
||||
timeoutMs: number,
|
||||
verbose: boolean,
|
||||
): Promise<CookieMap> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await get_free_port();
|
||||
const chrome = await launch_chrome(profileDir, port);
|
||||
const existingPort = await find_existing_chrome_debug_port(profileDir);
|
||||
const reusing = existingPort !== null;
|
||||
const port = existingPort ?? await get_free_port();
|
||||
const chrome = reusing ? null : await launch_chrome(profileDir, port);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
try {
|
||||
const wsUrl = await wait_for_chrome_debug_port(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>('Target.createTarget', {
|
||||
url: 'https://gemini.google.com/app',
|
||||
newWindow: true,
|
||||
});
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId, flatten: true });
|
||||
await cdp.send('Network.enable', {}, { sessionId });
|
||||
|
||||
if (verbose) {
|
||||
logger.info('Chrome opened. If needed, complete Google login in the window. Waiting for a valid Gemini session...');
|
||||
logger.info(reusing
|
||||
? `Reusing existing Chrome on port ${port}. Waiting for a valid Gemini session...`
|
||||
: 'Chrome opened. If needed, complete Google login in the window. Waiting for a valid Gemini session...');
|
||||
}
|
||||
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: GEMINI_APP_URL,
|
||||
matchTarget: (target) => target.type === 'page' && target.url.includes('gemini.google.com'),
|
||||
enableNetwork: true,
|
||||
});
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
|
||||
const start = Date.now();
|
||||
let last: CookieMap = {};
|
||||
|
||||
@@ -238,14 +139,14 @@ async function fetch_google_cookies_via_cdp(
|
||||
{ sessionId, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
const m: CookieMap = {};
|
||||
for (const c of cookies) {
|
||||
if (c?.name && typeof c.value === 'string') m[c.name] = c.value;
|
||||
const cookieMap: CookieMap = {};
|
||||
for (const cookie of cookies) {
|
||||
if (cookie?.name && typeof cookie.value === 'string') cookieMap[cookie.name] = cookie.value;
|
||||
}
|
||||
|
||||
last = m;
|
||||
if (await is_gemini_session_ready(m, verbose)) {
|
||||
return m;
|
||||
last = cookieMap;
|
||||
if (await is_gemini_session_ready(cookieMap, verbose)) {
|
||||
return cookieMap;
|
||||
}
|
||||
|
||||
await sleep(1000);
|
||||
@@ -254,22 +155,19 @@ async function fetch_google_cookies_via_cdp(
|
||||
throw new Error(`Timed out waiting for a valid Gemini session. Last keys: ${Object.keys(last).join(', ')}`);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
if (reusing && targetId) {
|
||||
try {
|
||||
await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
try {
|
||||
chrome.kill('SIGTERM');
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill('SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
if (chrome) killChrome(chrome);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,8 +182,8 @@ export async function load_browser_cookies(domain_name: string = '', verbose: bo
|
||||
const cookies = await fetch_google_cookies_via_cdp(profileDir, 120_000, verbose);
|
||||
|
||||
const filtered: CookieMap = {};
|
||||
for (const [k, v] of Object.entries(cookies)) {
|
||||
if (typeof v === 'string' && v.length > 0) filtered[k] = v;
|
||||
for (const [key, value] of Object.entries(cookies)) {
|
||||
if (typeof value === 'string' && value.length > 0) filtered[key] = value;
|
||||
}
|
||||
|
||||
await write_cookie_file(filtered, resolveGeminiWebCookiePath(), 'cdp');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
@@ -29,10 +30,23 @@ export function resolveGeminiWebCookiePath(): string {
|
||||
return path.join(resolveGeminiWebDataDir(), COOKIE_FILE_NAME);
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
export function resolveGeminiWebChromeProfileDir(): string {
|
||||
const override = process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.GEMINI_WEB_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveGeminiWebDataDir(), PROFILE_DIR_NAME);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, '.local', 'share', APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function resolveGeminiWebSessionsDir(): string {
|
||||
|
||||
@@ -59,16 +59,31 @@ function normalizeSessionMetadata(input: unknown): Array<string | null> {
|
||||
return [null, null, null];
|
||||
}
|
||||
|
||||
function formatScriptCommand(fallback: string): string {
|
||||
const raw = process.argv[1];
|
||||
const displayPath = raw
|
||||
? (() => {
|
||||
const relative = path.relative(process.cwd(), raw);
|
||||
return relative && !relative.startsWith("..") ? relative : raw;
|
||||
})()
|
||||
: fallback;
|
||||
const quotedPath = displayPath.includes(" ")
|
||||
? `"${displayPath.replace(/"/g, '\\"')}"`
|
||||
: displayPath;
|
||||
return `npx -y bun ${quotedPath}`;
|
||||
}
|
||||
|
||||
function printUsage(cookiePath: string, profileDir: string): void {
|
||||
const cmd = formatScriptCommand("scripts/main.ts");
|
||||
console.log(`Usage:
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "Hello"
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Hello"
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --prompt "A cute cat" --image generated.png
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
${cmd} --prompt "Hello"
|
||||
${cmd} "Hello"
|
||||
${cmd} --prompt "A cute cat" --image generated.png
|
||||
${cmd} --promptfiles system.md content.md --image out.png
|
||||
|
||||
Multi-turn conversation (agent generates unique sessionId):
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "Remember 42" --sessionId abc123
|
||||
npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts "What number?" --sessionId abc123
|
||||
${cmd} "Remember 42" --sessionId abc123
|
||||
${cmd} "What number?" --sessionId abc123
|
||||
|
||||
Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "baoyu-danger-gemini-web-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "baoyu-chrome-cdp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
win32?: string[];
|
||||
default: string[];
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type CdpSendOptions = {
|
||||
sessionId?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type FetchJsonOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type FindChromeExecutableOptions = {
|
||||
candidates: PlatformCandidates;
|
||||
envNames?: string[];
|
||||
};
|
||||
|
||||
type ResolveSharedChromeProfileDirOptions = {
|
||||
envNames?: string[];
|
||||
appDataDirName?: string;
|
||||
profileDirName?: string;
|
||||
wslWindowsHome?: string | null;
|
||||
};
|
||||
|
||||
type FindExistingChromeDebugPortOptions = {
|
||||
profileDir: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
port: number;
|
||||
url?: string;
|
||||
headless?: boolean;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
|
||||
type ChromeTargetInfo = {
|
||||
targetId: string;
|
||||
url: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type OpenPageSessionOptions = {
|
||||
cdp: CdpConnection;
|
||||
reusing: boolean;
|
||||
url: string;
|
||||
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||
enablePage?: boolean;
|
||||
enableRuntime?: boolean;
|
||||
enableDom?: boolean;
|
||||
enableNetwork?: boolean;
|
||||
activateTarget?: boolean;
|
||||
};
|
||||
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||
for (const envName of options.envNames ?? []) {
|
||||
const override = process.env[envName]?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
}
|
||||
|
||||
const candidates = process.platform === "darwin"
|
||||
? options.candidates.darwin ?? options.candidates.default
|
||||
: process.platform === "win32"
|
||||
? options.candidates.win32 ?? options.candidates.default
|
||||
: options.candidates.default;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||
for (const envName of options.envNames ?? []) {
|
||||
const override = process.env[envName]?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
}
|
||||
|
||||
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||
|
||||
if (options.wslWindowsHome) {
|
||||
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||
}
|
||||
|
||||
const base = process.platform === "darwin"
|
||||
? path.join(os.homedir(), "Library", "Application Support")
|
||||
: process.platform === "win32"
|
||||
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||
return path.join(base, appDataDirName, profileDirName);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
{ timeoutMs }
|
||||
);
|
||||
return !!version.webSocketDebuggerUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
options?: { includeLastError?: boolean }
|
||||
): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
{ timeoutMs: 5_000 }
|
||||
);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (options?.includeLastError && lastError) {
|
||||
throw new Error(
|
||||
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||
);
|
||||
}
|
||||
throw new Error("Chrome debug port not ready");
|
||||
}
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
private defaultTimeoutMs: number;
|
||||
|
||||
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||
this.ws = ws;
|
||||
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data = typeof event.data === "string"
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(msg.params));
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error("CDP connection closed."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
options?: { defaultTimeoutMs?: number }
|
||||
): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("CDP connection failed."));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
if (!this.eventHandlers.has(method)) {
|
||||
this.eventHandlers.set(method, new Set());
|
||||
}
|
||||
this.eventHandlers.get(method)?.add(handler);
|
||||
}
|
||||
|
||||
off(method: string, handler: (params: unknown) => void): void {
|
||||
this.eventHandlers.get(method)?.delete(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${options.port}`,
|
||||
`--user-data-dir=${options.profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
...(options.extraArgs ?? []),
|
||||
];
|
||||
if (options.headless) args.push("--headless=new");
|
||||
if (options.url) args.push(options.url);
|
||||
|
||||
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
export function killChrome(chrome: ChildProcess): void {
|
||||
try {
|
||||
chrome.kill("SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill("SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
}
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
if (existing) {
|
||||
targetId = existing.targetId;
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||
"Target.attachToTarget",
|
||||
{ targetId, flatten: true }
|
||||
);
|
||||
|
||||
if (options.activateTarget ?? true) {
|
||||
await options.cdp.send("Target.activateTarget", { targetId });
|
||||
}
|
||||
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
---
|
||||
name: baoyu-danger-x-to-markdown
|
||||
description: Converts X (Twitter) tweets and articles to markdown with YAML front matter. Uses reverse-engineered API requiring user consent. Use when user mentions "X to markdown", "tweet to markdown", "save tweet", or provides x.com/twitter.com URLs for conversion.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-danger-x-to-markdown
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# X to Markdown
|
||||
@@ -14,8 +22,9 @@ Converts X content to markdown:
|
||||
Scripts located in `scripts/` subdirectory.
|
||||
|
||||
**Path Resolution**:
|
||||
1. `SKILL_DIR` = this SKILL.md's directory
|
||||
2. Script path = `${SKILL_DIR}/scripts/main.ts`
|
||||
1. `{baseDir}` = this SKILL.md's directory
|
||||
2. Script path = `{baseDir}/scripts/main.ts`
|
||||
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
||||
|
||||
## Consent Requirement
|
||||
|
||||
@@ -69,16 +78,23 @@ Use `AskUserQuestion` with options: "Yes, I accept" | "No, I decline"
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-danger-x-to-markdown/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
┌────────────────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├────────────────────────────────────────────────────────────┼───────────────────┤
|
||||
@@ -135,10 +151,10 @@ Full reference: [references/config/first-time-setup.md](references/config/first-
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url>
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> -o output.md
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --download-media
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <url> --json
|
||||
${BUN_X} {baseDir}/scripts/main.ts <url>
|
||||
${BUN_X} {baseDir}/scripts/main.ts <url> -o output.md
|
||||
${BUN_X} {baseDir}/scripts/main.ts <url> --download-media
|
||||
${BUN_X} {baseDir}/scripts/main.ts <url> --json
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "baoyu-danger-x-to-markdown-scripts",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"baoyu-chrome-cdp": ["baoyu-chrome-cdp@file:vendor/baoyu-chrome-cdp", {}],
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import {
|
||||
CdpConnection,
|
||||
findChromeExecutable as findChromeExecutableBase,
|
||||
findExistingChromeDebugPort,
|
||||
getFreePort,
|
||||
killChrome,
|
||||
launchChrome as launchChromeBase,
|
||||
openPageSession,
|
||||
sleep,
|
||||
waitForChromeDebugPort,
|
||||
type PlatformCandidates,
|
||||
} from "baoyu-chrome-cdp";
|
||||
|
||||
import process from "node:process";
|
||||
|
||||
import { read_cookie_file, write_cookie_file } from "./cookie-file.js";
|
||||
@@ -9,199 +18,47 @@ import { resolveXToMarkdownCookiePath } from "./paths.js";
|
||||
import { X_COOKIE_NAMES, X_REQUIRED_COOKIES, X_LOGIN_URL, X_USER_DATA_DIR } from "./constants.js";
|
||||
import type { CookieLike } from "./types.js";
|
||||
|
||||
type CdpSendOptions = { sessionId?: string; timeoutMs?: number };
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
init: RequestInit & { timeoutMs?: number } = {}
|
||||
): Promise<Response> {
|
||||
const { timeoutMs, ...rest } = init;
|
||||
if (!timeoutMs || timeoutMs <= 0) return fetch(url, rest);
|
||||
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...rest, signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<
|
||||
number,
|
||||
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> | null }
|
||||
>();
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws;
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data =
|
||||
typeof event.data === "string"
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as { id?: number; result?: unknown; error?: { message?: string } };
|
||||
if (msg.id) {
|
||||
const p = this.pending.get(msg.id);
|
||||
if (p) {
|
||||
this.pending.delete(msg.id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
if (msg.error?.message) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
this.ws.addEventListener("close", () => {
|
||||
for (const [id, p] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (p.timer) clearTimeout(p.timer);
|
||||
p.reject(new Error("CDP connection closed."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(url: string, timeoutMs: number): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error("CDP connection failed."));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws);
|
||||
}
|
||||
|
||||
async send<T = unknown>(
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
opts?: CdpSendOptions
|
||||
): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const msg: Record<string, unknown> = { id, method };
|
||||
if (params) msg.params = params;
|
||||
if (opts?.sessionId) msg.sessionId = opts.sessionId;
|
||||
|
||||
const timeoutMs = opts?.timeoutMs ?? 15_000;
|
||||
const out = await new Promise<unknown>((resolve, reject) => {
|
||||
const t =
|
||||
timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer: t });
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
});
|
||||
return out as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const srv = net.createServer();
|
||||
srv.unref();
|
||||
srv.on("error", reject);
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") {
|
||||
srv.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||
return;
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
const CHROME_CANDIDATES_FULL: PlatformCandidates = {
|
||||
darwin: [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
],
|
||||
win32: [
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
],
|
||||
default: [
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/microsoft-edge",
|
||||
],
|
||||
};
|
||||
|
||||
function findChromeExecutable(): string | null {
|
||||
const override = process.env.X_CHROME_PATH?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
|
||||
const candidates: string[] = [];
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
candidates.push(
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
||||
);
|
||||
break;
|
||||
case "win32":
|
||||
candidates.push(
|
||||
"C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
||||
"C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe",
|
||||
"C:\\\\Program Files\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe",
|
||||
"C:\\\\Program Files (x86)\\\\Microsoft\\\\Edge\\\\Application\\\\msedge.exe"
|
||||
);
|
||||
break;
|
||||
default:
|
||||
candidates.push(
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
"/snap/bin/chromium",
|
||||
"/usr/bin/microsoft-edge"
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
return findChromeExecutableBase({
|
||||
candidates: CHROME_CANDIDATES_FULL,
|
||||
envNames: ["X_CHROME_PATH"],
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
async function waitForChromeDebugPort(port: number, timeoutMs: number): Promise<string> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(`http://127.0.0.1:${port}/json/version`, { timeoutMs: 5_000 });
|
||||
if (!res.ok) throw new Error(`status=${res.status}`);
|
||||
const j = (await res.json()) as { webSocketDebuggerUrl?: string };
|
||||
if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl;
|
||||
} catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
throw new Error("Chrome debug port not ready");
|
||||
}
|
||||
|
||||
async function launchChrome(profileDir: string, port: number): Promise<ChildProcess> {
|
||||
const chrome = findChromeExecutable();
|
||||
if (!chrome) throw new Error("Chrome executable not found.");
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--user-data-dir=${profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-popup-blocking",
|
||||
X_LOGIN_URL,
|
||||
];
|
||||
|
||||
return spawn(chrome, args, { stdio: "ignore" });
|
||||
async function launchChrome(profileDir: string, port: number) {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (!chromePath) throw new Error("Chrome executable not found.");
|
||||
return await launchChromeBase({
|
||||
chromePath,
|
||||
profileDir,
|
||||
port,
|
||||
url: X_LOGIN_URL,
|
||||
extraArgs: ["--disable-popup-blocking"],
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchXCookiesViaCdp(
|
||||
@@ -210,25 +67,33 @@ async function fetchXCookiesViaCdp(
|
||||
verbose: boolean,
|
||||
log?: (message: string) => void
|
||||
): Promise<Record<string, string>> {
|
||||
await mkdir(profileDir, { recursive: true });
|
||||
|
||||
const port = await getFreePort();
|
||||
const chrome = await launchChrome(profileDir, port);
|
||||
const existingPort = await findExistingChromeDebugPort({ profileDir });
|
||||
const reusing = existingPort !== null;
|
||||
const port = existingPort ?? await getFreePort("X_DEBUG_PORT");
|
||||
const chrome = reusing ? null : await launchChrome(profileDir, port);
|
||||
|
||||
let cdp: CdpConnection | null = null;
|
||||
let targetId: string | null = null;
|
||||
try {
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000);
|
||||
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
|
||||
cdp = await CdpConnection.connect(wsUrl, 15_000);
|
||||
|
||||
const { targetId } = await cdp.send<{ targetId: string }>("Target.createTarget", {
|
||||
const page = await openPageSession({
|
||||
cdp,
|
||||
reusing,
|
||||
url: X_LOGIN_URL,
|
||||
newWindow: true,
|
||||
matchTarget: (target) => target.type === "page" && (
|
||||
target.url.includes("x.com") || target.url.includes("twitter.com")
|
||||
),
|
||||
enableNetwork: true,
|
||||
});
|
||||
const { sessionId } = await cdp.send<{ sessionId: string }>("Target.attachToTarget", { targetId, flatten: true });
|
||||
await cdp.send("Network.enable", {}, { sessionId });
|
||||
const { sessionId } = page;
|
||||
targetId = page.targetId;
|
||||
|
||||
if (verbose) {
|
||||
log?.("[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies...");
|
||||
log?.(reusing
|
||||
? `[x-cookies] Reusing existing Chrome on port ${port}. Waiting for cookies...`
|
||||
: "[x-cookies] Chrome opened. If needed, complete X login in the window. Waiting for cookies...");
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
@@ -253,22 +118,19 @@ async function fetchXCookiesViaCdp(
|
||||
throw new Error(`Timed out waiting for X cookies. Last keys: ${Object.keys(last).join(", ")}`);
|
||||
} finally {
|
||||
if (cdp) {
|
||||
try {
|
||||
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
if (reusing && targetId) {
|
||||
try {
|
||||
await cdp.send("Target.closeTarget", { targetId }, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
|
||||
} catch {}
|
||||
}
|
||||
cdp.close();
|
||||
}
|
||||
|
||||
try {
|
||||
chrome.kill("SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill("SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
if (chrome) killChrome(chrome);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,8 +30,22 @@ type ConsentRecord = {
|
||||
|
||||
const DISCLAIMER_VERSION = "1.0";
|
||||
|
||||
function formatScriptCommand(fallback: string): string {
|
||||
const raw = process.argv[1];
|
||||
const displayPath = raw
|
||||
? (() => {
|
||||
const relative = path.relative(process.cwd(), raw);
|
||||
return relative && !relative.startsWith("..") ? relative : raw;
|
||||
})()
|
||||
: fallback;
|
||||
const quotedPath = displayPath.includes(" ")
|
||||
? `"${displayPath.replace(/"/g, '\\"')}"`
|
||||
: displayPath;
|
||||
return `npx -y bun ${quotedPath}`;
|
||||
}
|
||||
|
||||
function printUsage(exitCode: number): never {
|
||||
const cmd = "npx -y bun skills/baoyu-danger-x-to-markdown/scripts/main.ts";
|
||||
const cmd = formatScriptCommand("scripts/main.ts");
|
||||
console.log(`X (Twitter) to Markdown
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "baoyu-danger-x-to-markdown-scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"baoyu-chrome-cdp": "file:./vendor/baoyu-chrome-cdp"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
@@ -30,10 +31,23 @@ export function resolveXToMarkdownCookiePath(): string {
|
||||
return path.join(resolveXToMarkdownDataDir(), COOKIE_FILE_NAME);
|
||||
}
|
||||
|
||||
let _wslHome: string | null | undefined;
|
||||
function getWslWindowsHome(): string | null {
|
||||
if (_wslHome !== undefined) return _wslHome;
|
||||
if (!process.env.WSL_DISTRO_NAME) { _wslHome = null; return null; }
|
||||
try {
|
||||
const raw = execSync('cmd.exe /C "echo %USERPROFILE%"', { encoding: 'utf-8', timeout: 5000 }).trim().replace(/\r/g, '');
|
||||
_wslHome = execSync(`wslpath -u "${raw}"`, { encoding: 'utf-8', timeout: 5000 }).trim() || null;
|
||||
} catch { _wslHome = null; }
|
||||
return _wslHome;
|
||||
}
|
||||
|
||||
export function resolveXToMarkdownChromeProfileDir(): string {
|
||||
const override = process.env.X_CHROME_PROFILE_DIR?.trim();
|
||||
const override = process.env.BAOYU_CHROME_PROFILE_DIR?.trim() || process.env.X_CHROME_PROFILE_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return path.join(resolveXToMarkdownDataDir(), PROFILE_DIR_NAME);
|
||||
const wslHome = getWslWindowsHome();
|
||||
if (wslHome) return path.join(wslHome, ".local", "share", APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
return path.join(resolveUserDataRoot(), APP_DATA_DIR, PROFILE_DIR_NAME);
|
||||
}
|
||||
|
||||
export function resolveXToMarkdownConsentPath(): string {
|
||||
|
||||
@@ -36,6 +36,20 @@ function normalizeInputUrl(input: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatScriptCommand(fallback: string): string {
|
||||
const raw = process.argv[1];
|
||||
const displayPath = raw
|
||||
? (() => {
|
||||
const relative = path.relative(process.cwd(), raw);
|
||||
return relative && !relative.startsWith("..") ? relative : raw;
|
||||
})()
|
||||
: fallback;
|
||||
const quotedPath = displayPath.includes(" ")
|
||||
? `"${displayPath.replace(/"/g, '\\"')}"`
|
||||
: displayPath;
|
||||
return `npx -y bun ${quotedPath}`;
|
||||
}
|
||||
|
||||
function parseTweetId(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -179,7 +193,7 @@ async function main() {
|
||||
const { url } = parseArgs();
|
||||
if (!url) {
|
||||
console.error("Usage:");
|
||||
console.error(" npx -y bun skills/baoyu-danger-x-to-markdown/scripts/tweet-to-markdown.ts <tweet url>");
|
||||
console.error(` ${formatScriptCommand("scripts/tweet-to-markdown.ts")} <tweet url>`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "baoyu-chrome-cdp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export type PlatformCandidates = {
|
||||
darwin?: string[];
|
||||
win32?: string[];
|
||||
default: string[];
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type CdpSendOptions = {
|
||||
sessionId?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type FetchJsonOptions = {
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type FindChromeExecutableOptions = {
|
||||
candidates: PlatformCandidates;
|
||||
envNames?: string[];
|
||||
};
|
||||
|
||||
type ResolveSharedChromeProfileDirOptions = {
|
||||
envNames?: string[];
|
||||
appDataDirName?: string;
|
||||
profileDirName?: string;
|
||||
wslWindowsHome?: string | null;
|
||||
};
|
||||
|
||||
type FindExistingChromeDebugPortOptions = {
|
||||
profileDir: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type LaunchChromeOptions = {
|
||||
chromePath: string;
|
||||
profileDir: string;
|
||||
port: number;
|
||||
url?: string;
|
||||
headless?: boolean;
|
||||
extraArgs?: string[];
|
||||
};
|
||||
|
||||
type ChromeTargetInfo = {
|
||||
targetId: string;
|
||||
url: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type OpenPageSessionOptions = {
|
||||
cdp: CdpConnection;
|
||||
reusing: boolean;
|
||||
url: string;
|
||||
matchTarget: (target: ChromeTargetInfo) => boolean;
|
||||
enablePage?: boolean;
|
||||
enableRuntime?: boolean;
|
||||
enableDom?: boolean;
|
||||
enableNetwork?: boolean;
|
||||
activateTarget?: boolean;
|
||||
};
|
||||
|
||||
export type PageSession = {
|
||||
sessionId: string;
|
||||
targetId: string;
|
||||
};
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function getFreePort(fixedEnvName?: string): Promise<number> {
|
||||
const fixed = fixedEnvName ? Number.parseInt(process.env[fixedEnvName] ?? "", 10) : NaN;
|
||||
if (Number.isInteger(fixed) && fixed > 0) return fixed;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Unable to allocate a free TCP port.")));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
server.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function findChromeExecutable(options: FindChromeExecutableOptions): string | undefined {
|
||||
for (const envName of options.envNames ?? []) {
|
||||
const override = process.env[envName]?.trim();
|
||||
if (override && fs.existsSync(override)) return override;
|
||||
}
|
||||
|
||||
const candidates = process.platform === "darwin"
|
||||
? options.candidates.darwin ?? options.candidates.default
|
||||
: process.platform === "win32"
|
||||
? options.candidates.win32 ?? options.candidates.default
|
||||
: options.candidates.default;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSharedChromeProfileDir(options: ResolveSharedChromeProfileDirOptions = {}): string {
|
||||
for (const envName of options.envNames ?? []) {
|
||||
const override = process.env[envName]?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
}
|
||||
|
||||
const appDataDirName = options.appDataDirName ?? "baoyu-skills";
|
||||
const profileDirName = options.profileDirName ?? "chrome-profile";
|
||||
|
||||
if (options.wslWindowsHome) {
|
||||
return path.join(options.wslWindowsHome, ".local", "share", appDataDirName, profileDirName);
|
||||
}
|
||||
|
||||
const base = process.platform === "darwin"
|
||||
? path.join(os.homedir(), "Library", "Application Support")
|
||||
: process.platform === "win32"
|
||||
? (process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"))
|
||||
: (process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"));
|
||||
return path.join(base, appDataDirName, profileDirName);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs?: number): Promise<Response> {
|
||||
if (!timeoutMs || timeoutMs <= 0) return await fetch(url, { redirect: "follow" });
|
||||
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { redirect: "follow", signal: ctl.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T = unknown>(url: string, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const response = await fetchWithTimeout(url, options.timeoutMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
async function isDebugPortReady(port: number, timeoutMs = 3_000): Promise<boolean> {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
{ timeoutMs }
|
||||
);
|
||||
return !!version.webSocketDebuggerUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(options: FindExistingChromeDebugPortOptions): Promise<number | null> {
|
||||
const timeoutMs = options.timeoutMs ?? 3_000;
|
||||
const portFile = path.join(options.profileDir, "DevToolsActivePort");
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(portFile, "utf-8");
|
||||
const [portLine] = content.split(/\r?\n/);
|
||||
const port = Number.parseInt(portLine?.trim() ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
} catch {}
|
||||
|
||||
if (process.platform === "win32") return null;
|
||||
|
||||
try {
|
||||
const result = spawnSync("ps", ["aux"], { encoding: "utf-8", timeout: 5_000 });
|
||||
if (result.status !== 0 || !result.stdout) return null;
|
||||
|
||||
const lines = result.stdout
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(options.profileDir) && line.includes("--remote-debugging-port="));
|
||||
|
||||
for (const line of lines) {
|
||||
const portMatch = line.match(/--remote-debugging-port=(\d+)/);
|
||||
const port = Number.parseInt(portMatch?.[1] ?? "", 10);
|
||||
if (port > 0 && await isDebugPortReady(port, timeoutMs)) return port;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function waitForChromeDebugPort(
|
||||
port: number,
|
||||
timeoutMs: number,
|
||||
options?: { includeLastError?: boolean }
|
||||
): Promise<string> {
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
`http://127.0.0.1:${port}/json/version`,
|
||||
{ timeoutMs: 5_000 }
|
||||
);
|
||||
if (version.webSocketDebuggerUrl) return version.webSocketDebuggerUrl;
|
||||
lastError = new Error("Missing webSocketDebuggerUrl");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (options?.includeLastError && lastError) {
|
||||
throw new Error(
|
||||
`Chrome debug port not ready: ${lastError instanceof Error ? lastError.message : String(lastError)}`
|
||||
);
|
||||
}
|
||||
throw new Error("Chrome debug port not ready");
|
||||
}
|
||||
|
||||
export class CdpConnection {
|
||||
private ws: WebSocket;
|
||||
private nextId = 0;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private eventHandlers = new Map<string, Set<(params: unknown) => void>>();
|
||||
private defaultTimeoutMs: number;
|
||||
|
||||
private constructor(ws: WebSocket, defaultTimeoutMs = 15_000) {
|
||||
this.ws = ws;
|
||||
this.defaultTimeoutMs = defaultTimeoutMs;
|
||||
|
||||
this.ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const data = typeof event.data === "string"
|
||||
? event.data
|
||||
: new TextDecoder().decode(event.data as ArrayBuffer);
|
||||
const msg = JSON.parse(data) as {
|
||||
id?: number;
|
||||
method?: string;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (msg.method) {
|
||||
const handlers = this.eventHandlers.get(msg.method);
|
||||
if (handlers) {
|
||||
handlers.forEach((handler) => handler(msg.params));
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.id) {
|
||||
const pending = this.pending.get(msg.id);
|
||||
if (pending) {
|
||||
this.pending.delete(msg.id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
if (msg.error?.message) pending.reject(new Error(msg.error.message));
|
||||
else pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
this.ws.addEventListener("close", () => {
|
||||
for (const [id, pending] of this.pending.entries()) {
|
||||
this.pending.delete(id);
|
||||
if (pending.timer) clearTimeout(pending.timer);
|
||||
pending.reject(new Error("CDP connection closed."));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
options?: { defaultTimeoutMs?: number }
|
||||
): Promise<CdpConnection> {
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("CDP connection timeout.")), timeoutMs);
|
||||
ws.addEventListener("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener("error", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("CDP connection failed."));
|
||||
});
|
||||
});
|
||||
return new CdpConnection(ws, options?.defaultTimeoutMs ?? 15_000);
|
||||
}
|
||||
|
||||
on(method: string, handler: (params: unknown) => void): void {
|
||||
if (!this.eventHandlers.has(method)) {
|
||||
this.eventHandlers.set(method, new Set());
|
||||
}
|
||||
this.eventHandlers.get(method)?.add(handler);
|
||||
}
|
||||
|
||||
off(method: string, handler: (params: unknown) => void): void {
|
||||
this.eventHandlers.get(method)?.delete(handler);
|
||||
}
|
||||
|
||||
async send<T = unknown>(method: string, params?: Record<string, unknown>, options?: CdpSendOptions): Promise<T> {
|
||||
const id = ++this.nextId;
|
||||
const message: Record<string, unknown> = { id, method };
|
||||
if (params) message.params = params;
|
||||
if (options?.sessionId) message.sessionId = options.sessionId;
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const result = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP timeout: ${method}`));
|
||||
}, timeoutMs)
|
||||
: null;
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.ws.send(JSON.stringify(message));
|
||||
});
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
try {
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchChrome(options: LaunchChromeOptions): Promise<ChildProcess> {
|
||||
await fs.promises.mkdir(options.profileDir, { recursive: true });
|
||||
|
||||
const args = [
|
||||
`--remote-debugging-port=${options.port}`,
|
||||
`--user-data-dir=${options.profileDir}`,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
...(options.extraArgs ?? []),
|
||||
];
|
||||
if (options.headless) args.push("--headless=new");
|
||||
if (options.url) args.push(options.url);
|
||||
|
||||
return spawn(options.chromePath, args, { stdio: "ignore" });
|
||||
}
|
||||
|
||||
export function killChrome(chrome: ChildProcess): void {
|
||||
try {
|
||||
chrome.kill("SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
if (!chrome.killed) {
|
||||
try {
|
||||
chrome.kill("SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}, 2_000).unref?.();
|
||||
}
|
||||
|
||||
export async function openPageSession(options: OpenPageSessionOptions): Promise<PageSession> {
|
||||
let targetId: string;
|
||||
|
||||
if (options.reusing) {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
} else {
|
||||
const targets = await options.cdp.send<{ targetInfos: ChromeTargetInfo[] }>("Target.getTargets");
|
||||
const existing = targets.targetInfos.find(options.matchTarget);
|
||||
if (existing) {
|
||||
targetId = existing.targetId;
|
||||
} else {
|
||||
const created = await options.cdp.send<{ targetId: string }>("Target.createTarget", { url: options.url });
|
||||
targetId = created.targetId;
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionId } = await options.cdp.send<{ sessionId: string }>(
|
||||
"Target.attachToTarget",
|
||||
{ targetId, flatten: true }
|
||||
);
|
||||
|
||||
if (options.activateTarget ?? true) {
|
||||
await options.cdp.send("Target.activateTarget", { targetId });
|
||||
}
|
||||
if (options.enablePage) await options.cdp.send("Page.enable", {}, { sessionId });
|
||||
if (options.enableRuntime) await options.cdp.send("Runtime.enable", {}, { sessionId });
|
||||
if (options.enableDom) await options.cdp.send("DOM.enable", {}, { sessionId });
|
||||
if (options.enableNetwork) await options.cdp.send("Network.enable", {}, { sessionId });
|
||||
|
||||
return { sessionId, targetId };
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
---
|
||||
name: baoyu-format-markdown
|
||||
description: Formats plain text or markdown files with frontmatter, titles, summaries, headings, bold, lists, and code blocks. Use when user asks to "format markdown", "beautify article", "add formatting", or improve article layout. Outputs to {filename}-formatted.md.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-format-markdown
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Markdown Formatter
|
||||
|
||||
Transforms plain text or markdown files into well-structured markdown with proper frontmatter, formatting, and typography.
|
||||
Transforms plain text or markdown into well-structured, reader-friendly markdown. The goal is to help readers quickly grasp key points, highlights, and structure — without changing any original content.
|
||||
|
||||
**Core principle**: Only adjust formatting and fix obvious typos. Never add, delete, or rewrite content.
|
||||
|
||||
## Script Directory
|
||||
|
||||
Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's directory path.
|
||||
Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values.
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
@@ -19,16 +29,23 @@ Scripts in `scripts/` subdirectory. Replace `${SKILL_DIR}` with this SKILL.md's
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-format-markdown/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
┌──────────────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├──────────────────────────────────────────────────────────┼───────────────────┤
|
||||
@@ -45,55 +62,45 @@ test -f "$HOME/.baoyu-skills/baoyu-format-markdown/EXTEND.md" && echo "user"
|
||||
│ Not found │ Use defaults │
|
||||
└───────────┴───────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
**EXTEND.md Supports**: Default formatting options | Summary length preferences
|
||||
**EXTEND.md Supports**:
|
||||
|
||||
| Setting | Values | Default | Description |
|
||||
|---------|--------|---------|-------------|
|
||||
| `auto_select` | `true`/`false` | `false` | Skip both title and summary selection, auto-pick best |
|
||||
| `auto_select_title` | `true`/`false` | `false` | Skip title selection only |
|
||||
| `auto_select_summary` | `true`/`false` | `false` | Skip summary selection only |
|
||||
| Other | — | — | Default formatting options, typography preferences |
|
||||
|
||||
## Usage
|
||||
|
||||
Claude performs content analysis and formatting (Steps 1-6), then runs the script for typography fixes (Step 7).
|
||||
The workflow has two phases: **Analyze** (understand the content) then **Format** (apply formatting). Claude performs content analysis and formatting (Steps 1-5), then runs the script for typography fixes (Step 6).
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Read Source File
|
||||
### Step 1: Read & Detect Content Type
|
||||
|
||||
Read the user-specified markdown or plain text file.
|
||||
|
||||
### Step 1.5: Detect Content Type & Confirm
|
||||
|
||||
**Content Type Detection:**
|
||||
Read the user-specified file, then detect content type:
|
||||
|
||||
| Indicator | Classification |
|
||||
|-----------|----------------|
|
||||
| Has `---` YAML frontmatter | Markdown |
|
||||
| Has `#`, `##`, `###` headings | Markdown |
|
||||
| Has `**bold**`, `*italic*` | Markdown |
|
||||
| Has `- ` or `1. ` lists | Markdown |
|
||||
| Has ``` code blocks | Markdown |
|
||||
| Has `> ` blockquotes | Markdown |
|
||||
| Has `**bold**`, `*italic*`, lists, code blocks, blockquotes | Markdown |
|
||||
| None of above | Plain text |
|
||||
|
||||
**Decision Flow:**
|
||||
|
||||
┌─────────────────┬────────────────────────────────────────────────┐
|
||||
│ Content Type │ Action │
|
||||
├─────────────────┼────────────────────────────────────────────────┤
|
||||
│ Plain text │ Proceed to Step 2 (format to markdown) │
|
||||
├─────────────────┼────────────────────────────────────────────────┤
|
||||
│ Markdown │ Use AskUserQuestion to confirm optimization │
|
||||
└─────────────────┴────────────────────────────────────────────────┘
|
||||
|
||||
**If Markdown detected, ask user:**
|
||||
|
||||
```
|
||||
Detected existing markdown formatting. What would you like to do?
|
||||
|
||||
1. Optimize formatting (Recommended)
|
||||
- Add/improve frontmatter, headings, bold, lists
|
||||
- Analyze content, improve headings, bold, lists for readability
|
||||
- Run typography script (spacing, emphasis fixes)
|
||||
- Output: {filename}-formatted.md
|
||||
|
||||
2. Keep original formatting
|
||||
- Preserve existing markdown structure
|
||||
- Run typography script (spacing, emphasis fixes)
|
||||
- Run typography script only
|
||||
- Output: {filename}-formatted.md
|
||||
|
||||
3. Typography fixes only
|
||||
@@ -102,115 +109,205 @@ Detected existing markdown formatting. What would you like to do?
|
||||
```
|
||||
|
||||
**Based on user choice:**
|
||||
- **Optimize**: Continue to Step 2-8 (full workflow)
|
||||
- **Keep original**: Skip Steps 2-5, copy file → Step 6-8 (run script on copy)
|
||||
- **Typography only**: Skip Steps 2-6, run Step 7 on original file directly
|
||||
- **Optimize**: Continue to Step 2 (full workflow)
|
||||
- **Keep original**: Skip to Step 5, copy file then run Step 6
|
||||
- **Typography only**: Skip to Step 6, run on original file directly
|
||||
|
||||
### Step 2: Analyze Content Structure
|
||||
### Step 2: Analyze Content (Reader's Perspective)
|
||||
|
||||
Identify:
|
||||
- Existing title (H1 `#`)
|
||||
- Paragraph separations
|
||||
- Keywords suitable for **bold**
|
||||
- Parallel content convertible to lists
|
||||
- Code snippets
|
||||
- Quotations
|
||||
Read the entire content carefully. Think from a reader's perspective: what would help them quickly understand and remember the key information?
|
||||
|
||||
### Step 3: Check/Create Frontmatter
|
||||
Produce an analysis covering these dimensions:
|
||||
|
||||
**2.1 Highlights & Key Insights**
|
||||
- Core arguments or conclusions the author makes
|
||||
- Surprising facts, data points, or counterintuitive claims
|
||||
- Memorable quotes or well-phrased sentences (golden quotes)
|
||||
|
||||
**2.2 Structure Assessment**
|
||||
- Does the content have a clear logical flow? What is it?
|
||||
- Are there natural section boundaries that lack headings?
|
||||
- Are there long walls of text that could benefit from visual breaks?
|
||||
|
||||
**2.3 Reader-Important Information**
|
||||
- Actionable advice or takeaways
|
||||
- Definitions, explanations of key concepts
|
||||
- Lists or enumerations buried in prose
|
||||
- Comparisons or contrasts that would be clearer as tables
|
||||
|
||||
**2.4 Formatting Issues**
|
||||
- Missing or inconsistent heading hierarchy
|
||||
- Paragraphs that mix multiple topics
|
||||
- Parallel items written as prose instead of lists
|
||||
- Code, commands, or technical terms not marked as code
|
||||
- Obvious typos or formatting errors
|
||||
|
||||
**Save analysis to file**: `{original-filename}-analysis.md`
|
||||
|
||||
The analysis file serves as the blueprint for Step 3. Use this format:
|
||||
|
||||
```markdown
|
||||
# Content Analysis: {filename}
|
||||
|
||||
## Highlights & Key Insights
|
||||
- [list findings]
|
||||
|
||||
## Structure Assessment
|
||||
- Current flow: [describe]
|
||||
- Suggested sections: [list heading candidates with brief rationale]
|
||||
|
||||
## Reader-Important Information
|
||||
- [list actionable items, key concepts, buried lists, potential tables]
|
||||
|
||||
## Formatting Issues
|
||||
- [list specific issues with location references]
|
||||
|
||||
## Typos Found
|
||||
- [list any obvious typos with corrections, or "None found"]
|
||||
```
|
||||
|
||||
### Step 3: Check/Create Frontmatter, Title & Summary
|
||||
|
||||
Check for YAML frontmatter (`---` block). Create if missing.
|
||||
|
||||
**Meta field handling:**
|
||||
|
||||
| Field | Processing |
|
||||
|-------|------------|
|
||||
| `title` | See Step 4 |
|
||||
| `slug` | Infer from file path (e.g., `posts/2026/01/10/vibe-coding/` → `vibe-coding`) or generate from title |
|
||||
| `summary` | Generate engaging summary (100-150 characters) |
|
||||
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path (also accepted: `featureImage`) |
|
||||
| `title` | See **Title Generation** below |
|
||||
| `slug` | Infer from file path or generate from title |
|
||||
| `summary` | See **Summary Generation** below |
|
||||
| `coverImage` | Check if `imgs/cover.png` exists in same directory; if so, use relative path |
|
||||
|
||||
### Step 4: Title Handling
|
||||
**Title Generation:**
|
||||
|
||||
**Logic:**
|
||||
1. If frontmatter already has `title` → use it, no H1 in body
|
||||
2. If first line is H1 → extract to frontmatter `title`, remove H1 from body
|
||||
3. If neither exists → generate candidate titles
|
||||
Whether or not a title already exists, always run the title optimization flow (unless `auto_select_title` is set).
|
||||
|
||||
**Title generation flow:**
|
||||
**Preparation** — read the full text and extract:
|
||||
- Core argument (one sentence: "what is this article about?")
|
||||
- Most impactful opinion or conclusion
|
||||
- Reader pain point or curiosity trigger
|
||||
- Most memorable metaphor or golden quote
|
||||
|
||||
1. Generate 3 candidate titles based on content
|
||||
2. Use `AskUserQuestion` tool:
|
||||
**Generate 3-4 style-differentiated candidates:**
|
||||
|
||||
| Style | Characteristics | Example |
|
||||
|-------|----------------|---------|
|
||||
| Subversive | Deny common practice, create conflict | "All de-AI-flavor prompts are wrong" |
|
||||
| Solution | Give the answer directly, promise value | "One recipe to make AI write in your voice" |
|
||||
| Suspense | Reveal half, spark curiosity | "It took me six months to find how to remove AI flavor" |
|
||||
| Concrete number | Use numbers for credibility | "150 lines of docs taught AI my writing style" |
|
||||
|
||||
Present to user:
|
||||
|
||||
```
|
||||
Select a title:
|
||||
Pick a title:
|
||||
|
||||
1. [Title A] (Recommended)
|
||||
2. [Title B]
|
||||
3. [Title C]
|
||||
1. [Title A] — (recommended)
|
||||
2. [Title B] — [style note]
|
||||
3. [Title C] — [style note]
|
||||
|
||||
Enter number, or type a custom title:
|
||||
```
|
||||
|
||||
3. If no selection within a few seconds, use recommended (option 1)
|
||||
Put the strongest hook first and mark it (recommended).
|
||||
|
||||
**Title principles:**
|
||||
- Concise, max 20 characters
|
||||
- Captures core message
|
||||
- Engaging, sparks reading interest
|
||||
- Accurate, avoids clickbait
|
||||
- **Hook in first 5 chars**: create information gap or cognitive conflict
|
||||
- **Specific > abstract**: "150 lines" beats "a document"
|
||||
- **Negation > affirmation**: "you're doing it wrong" beats "the right way"
|
||||
- **Conversational**: like chatting with a friend, not a paper title
|
||||
- **Max ~25 chars**: longer titles get truncated in feeds
|
||||
- **Accurate, not clickbait**: the article must deliver what the title promises
|
||||
|
||||
**Important:** Once title is in frontmatter, body should NOT have H1 (avoid duplication)
|
||||
**Prohibited patterns:**
|
||||
- "浅谈 XX"、"关于 XX 的思考"、"XX 的探索与实践"
|
||||
- "震惊!"、"万字长文"、"建议收藏"
|
||||
- Pure questions without direction: "AI 写作的未来在哪里?"
|
||||
|
||||
### Step 5: Format Processing
|
||||
If first line is H1, extract to frontmatter and remove from body. If frontmatter already has `title`, include it as context but still generate fresh candidates.
|
||||
|
||||
**Formatting rules:**
|
||||
**Summary Generation:**
|
||||
|
||||
| Element | Format |
|
||||
|---------|--------|
|
||||
| Titles | Use `#`, `##`, `###` hierarchy |
|
||||
| Key points | Use `**bold**` |
|
||||
| Parallel items | Convert to `-` unordered or `1.` ordered lists |
|
||||
| Code/commands | Use `` `inline` `` or ` ```block``` ` |
|
||||
| Quotes/sayings | Use `>` blockquote |
|
||||
| Separators | Use `---` where appropriate |
|
||||
Generate 3 candidate summaries with different angles. Present to user:
|
||||
|
||||
**Formatting principles:**
|
||||
- Preserve original content and viewpoints
|
||||
- Add formatting only, do not modify text
|
||||
- Formatting serves readability
|
||||
- Avoid over-formatting
|
||||
```
|
||||
Pick a summary:
|
||||
|
||||
### Step 6: Save Formatted File
|
||||
1. [Summary A] — [focus note]
|
||||
2. [Summary B] — [focus note]
|
||||
3. [Summary C] — [focus note]
|
||||
|
||||
Enter number, or type a custom summary:
|
||||
```
|
||||
|
||||
**Summary principles:**
|
||||
- 80-150 characters, precise and information-rich
|
||||
- Convey **core value** to the reader, not just the topic
|
||||
- Vary angles: problem-driven, result-driven, insight-driven
|
||||
- **Hook the reader**: make them want to read the full article
|
||||
- Use concrete details (numbers, outcomes, specific methods) over vague descriptions
|
||||
|
||||
**Prohibited patterns:**
|
||||
- "本文介绍了..."、"本文探讨了..."
|
||||
- Pure topic description without value proposition
|
||||
- Repeating the title in different words
|
||||
|
||||
If frontmatter already has `summary`, skip selection and use it.
|
||||
|
||||
**EXTEND.md skip behavior:** If `auto_select: true` is set in EXTEND.md, skip title and summary selection — generate the best candidate directly without asking. User can also set `auto_select_title: true` or `auto_select_summary: true` independently.
|
||||
|
||||
Once title is in frontmatter, body should NOT have H1 (avoid duplication).
|
||||
|
||||
### Step 4: Format Content
|
||||
|
||||
Apply formatting guided by the Step 2 analysis. The goal is making the content scannable and the key points impossible to miss.
|
||||
|
||||
**Formatting toolkit:**
|
||||
|
||||
| Element | When to use | Format |
|
||||
|---------|-------------|--------|
|
||||
| Headings | Natural topic boundaries, section breaks | `##`, `###` hierarchy |
|
||||
| Bold | Key conclusions, important terms, core takeaways | `**bold**` |
|
||||
| Unordered lists | Parallel items, feature lists, examples | `- item` |
|
||||
| Ordered lists | Sequential steps, ranked items, procedures | `1. item` |
|
||||
| Tables | Comparisons, structured data, option matrices | Markdown table |
|
||||
| Code | Commands, file paths, technical terms, variable names | `` `inline` `` or fenced blocks |
|
||||
| Blockquotes | Notable quotes, important warnings, cited text | `> quote` |
|
||||
| Separators | Major topic transitions | `---` |
|
||||
|
||||
**Formatting principles — what NOT to do:**
|
||||
- Do NOT add sentences, explanations, or commentary
|
||||
- Do NOT delete or shorten any content
|
||||
- Do NOT rephrase or rewrite the author's words
|
||||
- Do NOT add headings that editorialize (e.g., "Amazing Discovery" — use neutral descriptive headings)
|
||||
- Do NOT over-format: not every sentence needs bold, not every paragraph needs a heading
|
||||
|
||||
**Formatting principles — what TO do:**
|
||||
- Preserve the author's voice, tone, and every word
|
||||
- **Bold key conclusions and core takeaways** — the sentences a reader would highlight
|
||||
- Extract parallel items from prose into lists only when the structure is clearly there
|
||||
- Add headings where the topic genuinely shifts — prefer vivid, specific headings over generic ones (e.g., "3 天搞定 vs 传统方案" over "方案对比")
|
||||
- Use tables for comparisons or structured data buried in prose
|
||||
- Use blockquotes for golden quotes, memorable statements, or important warnings
|
||||
- Fix obvious typos (based on Step 2 findings)
|
||||
|
||||
### Step 5: Save Formatted File
|
||||
|
||||
Save as `{original-filename}-formatted.md`
|
||||
|
||||
Examples:
|
||||
- `final.md` → `final-formatted.md`
|
||||
- `draft-v1.md` → `draft-v1-formatted.md`
|
||||
|
||||
**If user chose "Keep original formatting" (from Step 1.5):**
|
||||
- Copy original file to `{filename}-formatted.md` without modifications
|
||||
- Proceed to Step 7 for typography fixes only
|
||||
|
||||
**Backup existing file:**
|
||||
|
||||
If `{filename}-formatted.md` already exists, backup before overwriting:
|
||||
|
||||
```bash
|
||||
# Check if formatted file exists
|
||||
if [ -f "{filename}-formatted.md" ]; then
|
||||
# Backup with timestamp
|
||||
mv "{filename}-formatted.md" "{filename}-formatted.backup-$(date +%Y%m%d-%H%M%S).md"
|
||||
fi
|
||||
```
|
||||
|
||||
Example:
|
||||
- `final-formatted.md` exists → backup to `final-formatted.backup-20260128-143052.md`
|
||||
### Step 6: Execute Typography Script
|
||||
|
||||
### Step 7: Execute Text Formatting Script
|
||||
|
||||
After saving, **must** run the formatting script:
|
||||
Run the formatting script on the output file:
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts {output-file-path} [options]
|
||||
${BUN_X} {baseDir}/scripts/main.ts {output-file-path} [options]
|
||||
```
|
||||
|
||||
**Script Options:**
|
||||
@@ -223,75 +320,66 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts {output-file-path} [options]
|
||||
| `--no-spacing` | | Do not add CJK/English spacing | |
|
||||
| `--emphasis` | `-e` | Fix CJK emphasis punctuation issues | true |
|
||||
| `--no-emphasis` | | Do not fix CJK emphasis issues | |
|
||||
| `--help` | `-h` | Show help message | |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Default: spacing + emphasis enabled, quotes disabled
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md
|
||||
|
||||
# Enable all features including quote replacement
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --quotes
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --quotes
|
||||
|
||||
# Only fix emphasis issues, skip spacing
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --no-spacing
|
||||
|
||||
# Disable all processing except frontmatter formatting
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --no-spacing --no-emphasis
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --no-spacing
|
||||
```
|
||||
|
||||
**Script performs (based on options):**
|
||||
1. Fix CJK emphasis/bold punctuation issues (default: enabled)
|
||||
2. Add CJK/English mixed text spacing via autocorrect (default: enabled)
|
||||
3. Replace ASCII quotes `"..."` with fullwidth quotes `"..."` (default: disabled)
|
||||
3. Replace ASCII quotes with fullwidth quotes (default: disabled)
|
||||
4. Format frontmatter YAML (always enabled)
|
||||
|
||||
### Step 8: Display Results
|
||||
### Step 7: Completion Report
|
||||
|
||||
Display a report summarizing all changes made:
|
||||
|
||||
```
|
||||
**Formatting complete**
|
||||
**Formatting Complete**
|
||||
|
||||
File: posts/2026/01/09/example/final-formatted.md
|
||||
**Files:**
|
||||
- Analysis: {filename}-analysis.md
|
||||
- Formatted: {filename}-formatted.md
|
||||
|
||||
Changes:
|
||||
- Added title: [title content]
|
||||
- Added X bold markers
|
||||
- Added X lists
|
||||
- Added X code blocks
|
||||
**Content Analysis Summary:**
|
||||
- Highlights found: X key insights
|
||||
- Golden quotes: X memorable sentences
|
||||
- Formatting issues fixed: X items
|
||||
|
||||
**Changes Applied:**
|
||||
- Frontmatter: [added/updated] (title, slug, summary)
|
||||
- Headings added: X (##: N, ###: N)
|
||||
- Bold markers added: X
|
||||
- Lists created: X (from prose → list conversion)
|
||||
- Tables created: X
|
||||
- Code markers added: X
|
||||
- Blockquotes added: X
|
||||
- Typos fixed: X [list each: "original" → "corrected"]
|
||||
|
||||
**Typography Script:**
|
||||
- CJK spacing: [applied/skipped]
|
||||
- Emphasis fixes: [applied/skipped]
|
||||
- Quote replacement: [applied/skipped]
|
||||
```
|
||||
|
||||
## Formatting Example
|
||||
|
||||
**Before:**
|
||||
```
|
||||
This is plain text. First point is efficiency improvement. Second point is cost reduction. Third point is experience optimization. Use npm install to install dependencies.
|
||||
```
|
||||
|
||||
**After:**
|
||||
```markdown
|
||||
---
|
||||
title: Three Core Advantages
|
||||
slug: three-core-advantages
|
||||
summary: Discover the three key benefits that drive success in modern projects.
|
||||
---
|
||||
|
||||
This is plain text.
|
||||
|
||||
**Main advantages:**
|
||||
- Efficiency improvement
|
||||
- Cost reduction
|
||||
- Experience optimization
|
||||
|
||||
Use `npm install` to install dependencies.
|
||||
```
|
||||
Adjust the report to reflect actual changes — omit categories where no changes were made.
|
||||
|
||||
## Notes
|
||||
|
||||
- Preserve original writing style and tone
|
||||
- Specify correct language for code blocks (e.g., `python`, `javascript`)
|
||||
- Maintain CJK/English spacing standards
|
||||
- Do not add content not present in original
|
||||
- The analysis file is a working document — it helps maintain consistency between what was identified and what was formatted
|
||||
|
||||
## Extension Support
|
||||
|
||||
|
||||
@@ -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 {
|
||||
try {
|
||||
execSync(`npx autocorrect-node --fix "${filePath}"`, { stdio: "inherit" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const npxCmd = process.platform === "win32" ? "npx.cmd" : "npx";
|
||||
const result = spawnSync(npxCmd, ["autocorrect-node", "--fix", filePath], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
+121
-49
@@ -1,6 +1,14 @@
|
||||
---
|
||||
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
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-image-gen
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Image Generation (AI SDK)
|
||||
@@ -10,8 +18,9 @@ Official API-based image generation. Supports OpenAI, Google, DashScope (阿里
|
||||
## Script Directory
|
||||
|
||||
**Agent Execution**:
|
||||
1. `SKILL_DIR` = this SKILL.md file's directory
|
||||
2. Script path = `${SKILL_DIR}/scripts/main.ts`
|
||||
1. `{baseDir}` = this SKILL.md file's directory
|
||||
2. Script path = `{baseDir}/scripts/main.ts`
|
||||
3. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun
|
||||
|
||||
## Step 0: Load Preferences ⛔ BLOCKING
|
||||
|
||||
@@ -20,10 +29,20 @@ Official API-based image generation. Supports OpenAI, Google, DashScope (阿里
|
||||
Check EXTEND.md existence (priority: project → user):
|
||||
|
||||
```bash
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
| Result | Action |
|
||||
|--------|--------|
|
||||
| Found | Load, parse, apply settings. If `default_model.[provider]` is null → ask model only (Flow 2) |
|
||||
@@ -36,7 +55,7 @@ test -f "$HOME/.baoyu-skills/baoyu-image-gen/EXTEND.md" && echo "user"
|
||||
| `.baoyu-skills/baoyu-image-gen/EXTEND.md` | Project directory |
|
||||
| `$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`
|
||||
|
||||
@@ -44,50 +63,85 @@ Schema: `references/config/preferences-schema.md`
|
||||
|
||||
```bash
|
||||
# Basic
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image cat.png
|
||||
|
||||
# With aspect ratio
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A landscape" --image out.png --ar 16:9
|
||||
|
||||
# High quality
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --quality 2k
|
||||
|
||||
# From prompt files
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --promptfiles system.md content.md --image out.png
|
||||
|
||||
# With reference images (Google multimodal or OpenAI edits)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --ref source.png
|
||||
|
||||
# With reference images (explicit provider/model)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "Make blue" --image out.png --provider google --model gemini-3-pro-image-preview --ref source.png
|
||||
|
||||
# Specific provider
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider openai
|
||||
|
||||
# DashScope (阿里通义万象)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "一只可爱的猫" --image out.png --provider dashscope
|
||||
|
||||
# Replicate (google/nano-banana-pro)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Replicate with specific model
|
||||
npx -y bun ${SKILL_DIR}/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
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--prompt <text>`, `-p` | Prompt text |
|
||||
| `--promptfiles <files...>` | Read prompt from files (concatenated) |
|
||||
| `--image <path>` | Output image path (required) |
|
||||
| `--provider google\|openai\|dashscope\|replicate` | Force provider (default: google) |
|
||||
| `--model <id>`, `-m` | Model ID (Google: `gemini-3-pro-image-preview`, `gemini-3.1-flash-image-preview`; OpenAI: `gpt-image-1.5`) |
|
||||
| `--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 (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`) |
|
||||
| `--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) |
|
||||
| `--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 |
|
||||
| `--json` | JSON output |
|
||||
|
||||
@@ -107,17 +161,28 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope 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`
|
||||
|
||||
## Replicate Model Configuration
|
||||
## Model Resolution
|
||||
|
||||
When using `--provider replicate`, the model can be configured in the following ways (highest priority first):
|
||||
Model priority (highest → lowest), applies to all providers:
|
||||
|
||||
1. CLI flag: `--model <owner/name>`
|
||||
2. EXTEND.md: `default_model.replicate`
|
||||
3. Env var: `REPLICATE_IMAGE_MODEL`
|
||||
4. Built-in default: `google/nano-banana-pro`
|
||||
1. CLI flag: `--model <id>`
|
||||
2. EXTEND.md: `default_model.[provider]`
|
||||
3. Env var: `<PROVIDER>_IMAGE_MODEL` (e.g., `GOOGLE_IMAGE_MODEL`)
|
||||
4. Built-in default
|
||||
|
||||
**EXTEND.md overrides env vars**. If both EXTEND.md `default_model.google: "gemini-3-pro-image-preview"` and env var `GOOGLE_IMAGE_MODEL=gemini-3.1-flash-image-preview` exist, EXTEND.md wins.
|
||||
|
||||
**Agent MUST display model info** before each generation:
|
||||
- Show: `Using [provider] / [model]`
|
||||
- Show switch hint: `Switch model: --model <id> | EXTEND.md default_model.[provider] | env <PROVIDER>_IMAGE_MODEL`
|
||||
|
||||
### Replicate Models
|
||||
|
||||
Supported model formats:
|
||||
|
||||
@@ -128,10 +193,10 @@ Examples:
|
||||
|
||||
```bash
|
||||
# Use Replicate default model
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
${BUN_X} {baseDir}/scripts/main.ts --prompt "A cat" --image out.png --provider replicate
|
||||
|
||||
# Override model explicitly
|
||||
npx -y bun ${SKILL_DIR}/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
|
||||
```
|
||||
|
||||
## Provider Selection
|
||||
@@ -143,10 +208,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
||||
|
||||
## Quality Presets
|
||||
|
||||
| Preset | Google imageSize | OpenAI Size | Use Case |
|
||||
|--------|------------------|-------------|----------|
|
||||
| `normal` | 1K | 1024px | Quick previews |
|
||||
| `2k` (default) | 2K | 2048px | Covers, illustrations, infographics |
|
||||
| Preset | Google imageSize | OpenAI Size | Replicate resolution | Use Case |
|
||||
|--------|------------------|-------------|----------------------|----------|
|
||||
| `normal` | 1K | 1024px | 1K | Quick previews |
|
||||
| `2k` (default) | 2K | 2048px | 2K | Covers, illustrations, infographics |
|
||||
|
||||
**Google imageSize**: Can be overridden with `--imageSize 1K|2K|4K`
|
||||
|
||||
@@ -155,41 +220,48 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts --prompt "A cat" --image out.png --provi
|
||||
Supported: `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `2.35:1`
|
||||
|
||||
- Google multimodal: uses `imageConfig.aspectRatio`
|
||||
- Google Imagen: uses `aspectRatio` parameter
|
||||
- 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
|
||||
|
||||
**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 |
|
||||
|------|-------------|
|
||||
| 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 |
|
||||
|---------|-------|
|
||||
| Recommended concurrency | 4 subagents |
|
||||
| Max concurrency | 8 subagents |
|
||||
| Use case | Large batch generation when user requests parallel |
|
||||
| Situation | Preferred approach | Why |
|
||||
|-----------|--------------------|-----|
|
||||
| One image, or 1-2 simple images | Sequential | Lower coordination overhead and easier debugging |
|
||||
| Multiple images already have saved prompt files | Batch (`--batchfile`) | Reuses finalized prompts, applies shared throttling/retries, and gives predictable throughput |
|
||||
| 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):
|
||||
```
|
||||
# Launch multiple generations in parallel using Task tool
|
||||
# Each Task runs as background subagent with run_in_background=true
|
||||
# Collect results via TaskOutput when all complete
|
||||
```
|
||||
Rule of thumb:
|
||||
|
||||
- Prefer batch over subagents once prompt files are already saved and the task is "generate all of these"
|
||||
- Use subagents only when generation is coupled with per-image thinking, rewriting, or divergent creative exploration
|
||||
|
||||
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
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -21,9 +21,25 @@ default_image_size: null # 1K|2K|4K|null (Google only, overrides quality)
|
||||
|
||||
default_model:
|
||||
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"
|
||||
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.dashscope` | string\|null | null | DashScope 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
|
||||
|
||||
@@ -65,5 +84,11 @@ default_model:
|
||||
openai: "gpt-image-1.5"
|
||||
dashscope: "z-image-turbo"
|
||||
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 { homedir } from "node:os";
|
||||
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 {
|
||||
console.log(`Usage:
|
||||
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 --batchfile batch.json
|
||||
|
||||
Options:
|
||||
-p, --prompt <text> Prompt text
|
||||
--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)
|
||||
-m, --model <id> Model ID
|
||||
--ar <ratio> Aspect ratio (e.g., 16:9, 1:1, 4:3)
|
||||
--size <WxH> Size (e.g., 1024x1024)
|
||||
--quality normal|2k Quality preset (default: 2k)
|
||||
--imageSize 1K|2K|4K Image size for Google (default: from quality)
|
||||
--ref <files...> Reference images (Google multimodal or OpenAI edits)
|
||||
--n <count> Number of images (default: 1)
|
||||
--ref <files...> Reference images (Google multimodal, OpenAI GPT Image edits, or Replicate)
|
||||
--n <count> Number of images for the current task (default: 1)
|
||||
--json JSON output
|
||||
-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:
|
||||
OPENAI_API_KEY OpenAI API key
|
||||
GOOGLE_API_KEY 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
|
||||
OPENAI_IMAGE_MODEL Default OpenAI model (gpt-image-1.5)
|
||||
GOOGLE_IMAGE_MODEL Default Google model (gemini-3-pro-image-preview)
|
||||
@@ -40,6 +114,9 @@ Environment variables:
|
||||
GOOGLE_BASE_URL Custom Google endpoint
|
||||
DASHSCOPE_BASE_URL Custom DashScope 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`);
|
||||
}
|
||||
@@ -57,6 +134,8 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
imageSize: null,
|
||||
referenceImages: [],
|
||||
n: 1,
|
||||
batchFile: null,
|
||||
jobs: null,
|
||||
json: false,
|
||||
help: false,
|
||||
};
|
||||
@@ -110,9 +189,26 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
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") {
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
@@ -228,9 +324,11 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
const config: Partial<ExtendConfig> = {};
|
||||
const lines = yaml.split("\n");
|
||||
let currentKey: string | null = null;
|
||||
let currentProvider: Provider | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
|
||||
if (trimmed.includes(":") && !trimmed.startsWith("-")) {
|
||||
@@ -247,18 +345,57 @@ function parseSimpleYaml(yaml: string): Partial<ExtendConfig> {
|
||||
} else if (key === "default_provider") {
|
||||
config.default_provider = value === "null" ? null : (value as Provider);
|
||||
} 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") {
|
||||
const cleaned = value.replace(/['"]/g, "");
|
||||
config.default_aspect_ratio = cleaned === "null" ? null : cleaned;
|
||||
} 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") {
|
||||
config.default_model = { google: null, openai: null, dashscope: null, replicate: null };
|
||||
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, "");
|
||||
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 yaml = extractYamlFrontMatter(content);
|
||||
if (!yaml) continue;
|
||||
|
||||
return parseSimpleYaml(yaml);
|
||||
} catch {
|
||||
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> {
|
||||
const parts: string[] = [];
|
||||
for (const f of files) {
|
||||
@@ -311,9 +498,12 @@ async function readPromptFromFiles(files: string[]): Promise<string> {
|
||||
async function readPromptFromStdin(): Promise<string | null> {
|
||||
if (process.stdin.isTTY) return null;
|
||||
try {
|
||||
const t = await Bun.stdin.text();
|
||||
const v = t.trim();
|
||||
return v.length > 0 ? v : null;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const value = Buffer.concat(chunks).toString("utf8").trim();
|
||||
return value.length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -327,7 +517,13 @@ function normalizeOutputImagePath(p: string): string {
|
||||
}
|
||||
|
||||
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(
|
||||
"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]!;
|
||||
@@ -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 {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const nonRetryableMarkers = [
|
||||
@@ -384,26 +580,353 @@ function isRetryableGenerationError(error: unknown): boolean {
|
||||
"only supported",
|
||||
"No API key found",
|
||||
"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));
|
||||
}
|
||||
|
||||
async function loadProviderModule(provider: Provider): Promise<ProviderModule> {
|
||||
if (provider === "google") {
|
||||
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 === "google") 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;
|
||||
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> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (args.help) {
|
||||
printUsage();
|
||||
return;
|
||||
@@ -412,86 +935,18 @@ async function main(): Promise<void> {
|
||||
await loadEnv();
|
||||
const extendConfig = await loadExtendConfig();
|
||||
const mergedArgs = mergeConfig(args, extendConfig);
|
||||
|
||||
if (!mergedArgs.quality) mergedArgs.quality = "2k";
|
||||
|
||||
let prompt: string | null = mergedArgs.prompt;
|
||||
if (!prompt && mergedArgs.promptFiles.length > 0) prompt = await readPromptFromFiles(mergedArgs.promptFiles);
|
||||
if (!prompt) prompt = await readPromptFromStdin();
|
||||
|
||||
if (!prompt) {
|
||||
console.error("Error: Prompt is required");
|
||||
printUsage();
|
||||
process.exitCode = 1;
|
||||
if (mergedArgs.batchFile) {
|
||||
await runBatchMode(mergedArgs, extendConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mergedArgs.imagePath) {
|
||||
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);
|
||||
}
|
||||
await runSingleMode(mergedArgs, extendConfig);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(msg);
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -22,27 +22,53 @@ function parseAspectRatio(ar: string): { width: number; height: number } | null
|
||||
return { width: w, height: h };
|
||||
}
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const baseSize = quality === "2k" ? 1440 : 1024;
|
||||
const STANDARD_SIZES: [number, number][] = [
|
||||
[1024, 1024],
|
||||
[1280, 720],
|
||||
[720, 1280],
|
||||
[1024, 768],
|
||||
[768, 1024],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
];
|
||||
|
||||
if (!ar) return `${baseSize}*${baseSize}`;
|
||||
const STANDARD_SIZES_2K: [number, number][] = [
|
||||
[1536, 1536],
|
||||
[2048, 1152],
|
||||
[1152, 2048],
|
||||
[1536, 1024],
|
||||
[1024, 1536],
|
||||
[1536, 864],
|
||||
[864, 1536],
|
||||
[2048, 2048],
|
||||
];
|
||||
|
||||
function getSizeFromAspectRatio(ar: string | null, quality: CliArgs["quality"]): string {
|
||||
const is2k = quality === "2k";
|
||||
const defaultSize = is2k ? "1536*1536" : "1024*1024";
|
||||
|
||||
if (!ar) return defaultSize;
|
||||
|
||||
const parsed = parseAspectRatio(ar);
|
||||
if (!parsed) return `${baseSize}*${baseSize}`;
|
||||
if (!parsed) return defaultSize;
|
||||
|
||||
const ratio = parsed.width / parsed.height;
|
||||
const targetRatio = parsed.width / parsed.height;
|
||||
const sizes = is2k ? STANDARD_SIZES_2K : STANDARD_SIZES;
|
||||
|
||||
if (Math.abs(ratio - 1) < 0.1) {
|
||||
return `${baseSize}*${baseSize}`;
|
||||
let best = defaultSize;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [w, h] of sizes) {
|
||||
const diff = Math.abs(w / h - targetRatio);
|
||||
if (diff < bestDiff) {
|
||||
bestDiff = diff;
|
||||
best = `${w}*${h}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (ratio > 1) {
|
||||
const w = Math.round(baseSize * ratio);
|
||||
return `${w}*${baseSize}`;
|
||||
}
|
||||
|
||||
const h = Math.round(baseSize / ratio);
|
||||
return `${baseSize}*${h}`;
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalizeSize(size: string): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { CliArgs } from "../types";
|
||||
|
||||
const GOOGLE_MULTIMODAL_MODELS = [
|
||||
@@ -76,14 +76,43 @@ async function postGoogleJsonViaCurl<T>(
|
||||
): Promise<T> {
|
||||
const proxy = getHttpProxy();
|
||||
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(
|
||||
`curl -s --connect-timeout 30 --max-time 300 ${proxyArgs} "${url}" -H "Content-Type: application/json" -H "x-goog-api-key: ${apiKey}" -d @-`,
|
||||
{ input: bodyStr, maxBuffer: 100 * 1024 * 1024, timeout: 310000 },
|
||||
);
|
||||
let result = "";
|
||||
try {
|
||||
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) {
|
||||
throw new Error(
|
||||
`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 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") {
|
||||
return generateWithChatCompletions(baseURL, apiKey, prompt, model);
|
||||
|
||||
@@ -36,22 +36,24 @@ function buildInput(prompt: string, args: CliArgs, referenceImages: string[]): R
|
||||
|
||||
if (args.aspectRatio) {
|
||||
input.aspect_ratio = args.aspectRatio;
|
||||
} else if (referenceImages.length > 0) {
|
||||
input.aspect_ratio = "match_input_image";
|
||||
}
|
||||
|
||||
if (args.n > 1) {
|
||||
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";
|
||||
|
||||
if (referenceImages.length > 0) {
|
||||
if (referenceImages.length === 1) {
|
||||
input.image = referenceImages[0];
|
||||
} else {
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
input[`image${i > 0 ? i + 1 : ""}`] = referenceImages[i];
|
||||
}
|
||||
}
|
||||
input.image_input = referenceImages;
|
||||
}
|
||||
|
||||
return input;
|
||||
|
||||
@@ -13,10 +13,34 @@ export type CliArgs = {
|
||||
imageSize: string | null;
|
||||
referenceImages: string[];
|
||||
n: number;
|
||||
batchFile: string | null;
|
||||
jobs: number | null;
|
||||
json: 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 = {
|
||||
version: number;
|
||||
default_provider: Provider | null;
|
||||
@@ -29,4 +53,16 @@ export type ExtendConfig = {
|
||||
dashscope: string | null;
|
||||
replicate: string | null;
|
||||
};
|
||||
batch?: {
|
||||
max_workers?: number | null;
|
||||
provider_limits?: Partial<
|
||||
Record<
|
||||
Provider,
|
||||
{
|
||||
concurrency?: number | null;
|
||||
start_interval_ms?: number | null;
|
||||
}
|
||||
>
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
name: baoyu-infographic
|
||||
description: Generates professional infographics with 21 layout types and 20 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "信息图", "visual summary", "可视化", or "高密度信息大图".
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-infographic
|
||||
---
|
||||
|
||||
# Infographic Generator
|
||||
@@ -128,7 +132,7 @@ Slug: 2-4 words kebab-case from topic. Conflict: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
## Core Principles
|
||||
|
||||
- Preserve all source data **verbatim**—no summarization or rephrasing
|
||||
- Preserve source data faithfully—no summarization or rephrasing (but **strip any credentials, API keys, tokens, or secrets** before including in outputs)
|
||||
- Define learning objectives before structuring content
|
||||
- Structure for visual communication (headlines, labels, visual elements)
|
||||
|
||||
@@ -138,16 +142,23 @@ Slug: 2-4 words kebab-case from topic. Conflict: append `-YYYYMMDD-HHMMSS`.
|
||||
|
||||
**1.1 Load Preferences (EXTEND.md)**
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-infographic/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
┌────────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├────────────────────────────────────────────────────┼───────────────────┤
|
||||
@@ -188,7 +199,7 @@ Transform content into infographic structure:
|
||||
3. Data points (all statistics/quotes copied exactly)
|
||||
4. Design instructions from user
|
||||
|
||||
**Rules**: Markdown only. No new information. All data verbatim.
|
||||
**Rules**: Markdown only. No new information. Preserve data faithfully. Strip any credentials or secrets from output.
|
||||
|
||||
See `references/structured-content-template.md` for detailed format.
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
---
|
||||
name: baoyu-markdown-to-html
|
||||
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, and infographics. Use when user asks for "markdown to html", "convert md to html", "md转html", or needs styled HTML output from markdown.
|
||||
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md转html", "微信外链转底部引用", or needs styled HTML output from markdown.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-markdown-to-html
|
||||
requires:
|
||||
anyBins:
|
||||
- bun
|
||||
- npx
|
||||
---
|
||||
|
||||
# Markdown to HTML Converter
|
||||
@@ -9,7 +17,7 @@ Converts Markdown files to beautifully styled HTML with inline CSS, optimized fo
|
||||
|
||||
## Script Directory
|
||||
|
||||
**Agent Execution**: Determine this SKILL.md directory as `SKILL_DIR`, then use `${SKILL_DIR}/scripts/<name>.ts`.
|
||||
**Agent Execution**: Determine this SKILL.md directory as `{baseDir}`. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values.
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
@@ -17,16 +25,23 @@ Converts Markdown files to beautifully styled HTML with inline CSS, optimized fo
|
||||
|
||||
## Preferences (EXTEND.md)
|
||||
|
||||
Use Bash to check EXTEND.md existence (priority order):
|
||||
Check EXTEND.md existence (priority order):
|
||||
|
||||
```bash
|
||||
# Check project-level first
|
||||
# macOS, Linux, WSL, Git Bash
|
||||
test -f .baoyu-skills/baoyu-markdown-to-html/EXTEND.md && echo "project"
|
||||
|
||||
# Then user-level (cross-platform: $HOME works on macOS/Linux/WSL)
|
||||
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"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
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" }
|
||||
```
|
||||
|
||||
┌──────────────────────────────────────────────────────────────┬───────────────────┐
|
||||
│ Path │ Location │
|
||||
├──────────────────────────────────────────────────────────────┼───────────────────┤
|
||||
@@ -83,22 +98,37 @@ Use `AskUserQuestion` to ask whether to format first. Formatting can fix:
|
||||
test -f "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" && grep -o 'default_theme:.*' "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md"
|
||||
```
|
||||
|
||||
```powershell
|
||||
# PowerShell (Windows)
|
||||
if (Test-Path "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md") { Select-String -Pattern 'default_theme:.*' -Path "$HOME/.baoyu-skills/baoyu-post-to-wechat/EXTEND.md" | ForEach-Object { $_.Matches.Value } }
|
||||
```
|
||||
|
||||
**If theme is resolved from EXTEND.md**: Use it directly, do NOT ask the user.
|
||||
|
||||
**If no default found**: Use AskUserQuestion to confirm:
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `default` (Recommended) | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 |
|
||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 |
|
||||
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
|
||||
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
|
||||
| `default` (Recommended) | Classic - traditional layout, centered title with bottom border, H2 with white text on colored background |
|
||||
| `grace` | Elegant - text shadow, rounded cards, refined blockquotes |
|
||||
| `simple` | Minimal - modern minimalist, asymmetric rounded corners, clean whitespace |
|
||||
| `modern` | Modern - large radius, pill-shaped titles, relaxed line height (pair with `--color red` for traditional red-gold style) |
|
||||
|
||||
### Step 1.5: Determine Citation Mode
|
||||
|
||||
**Default**: Off. Do not ask by default.
|
||||
|
||||
**Enable only if the user explicitly asks** for "微信外链转底部引用", "底部引用", "文末引用", or passes `--cite`.
|
||||
|
||||
**Behavior when enabled**:
|
||||
- Ordinary external links are rendered with numbered superscripts and collected under a final `引用链接` section.
|
||||
- `https://mp.weixin.qq.com/...` links stay as direct links and are not moved to the bottom.
|
||||
- Bare links where link text equals URL stay inline.
|
||||
|
||||
### Step 2: Convert
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> --theme <theme>
|
||||
${BUN_X} {baseDir}/scripts/main.ts <markdown_file> --theme <theme> [--cite]
|
||||
```
|
||||
|
||||
### Step 3: Report Result
|
||||
@@ -108,32 +138,60 @@ Display the output path from JSON result. If backup was created, mention it.
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts <markdown_file> [options]
|
||||
${BUN_X} {baseDir}/scripts/main.ts <markdown_file> [options]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--theme <name>` | Theme name (default, grace, simple, red, orange) | default |
|
||||
| `--theme <name>` | Theme name (default, grace, simple, modern) | default |
|
||||
| `--color <name\|hex>` | Primary color: preset name or hex value | theme default |
|
||||
| `--font-family <name>` | Font: sans, serif, serif-cjk, mono, or CSS value | theme default |
|
||||
| `--font-size <N>` | Font size: 14px, 15px, 16px, 17px, 18px | 16px |
|
||||
| `--title <title>` | Override title from frontmatter | |
|
||||
| `--cite` | Convert external links to bottom citations, append `引用链接` section | false (off) |
|
||||
| `--keep-title` | Keep the first heading in content | false (removed) |
|
||||
| `--help` | Show help | |
|
||||
|
||||
**Color Presets:**
|
||||
|
||||
| Name | Hex | Label |
|
||||
|------|-----|-------|
|
||||
| blue | #0F4C81 | Classic Blue |
|
||||
| green | #009874 | Emerald Green |
|
||||
| vermilion | #FA5151 | Vibrant Vermilion |
|
||||
| yellow | #FECE00 | Lemon Yellow |
|
||||
| purple | #92617E | Lavender Purple |
|
||||
| sky | #55C9EA | Sky Blue |
|
||||
| rose | #B76E79 | Rose Gold |
|
||||
| olive | #556B2F | Olive Green |
|
||||
| black | #333333 | Graphite Black |
|
||||
| gray | #A9A9A9 | Smoke Gray |
|
||||
| pink | #FFB7C5 | Sakura Pink |
|
||||
| red | #A93226 | China Red |
|
||||
| orange | #D97757 | Warm Orange (modern default) |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Basic conversion (uses default theme, removes first heading)
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md
|
||||
|
||||
# With specific theme
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --theme grace
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --theme grace
|
||||
|
||||
# Theme with custom color
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --theme modern --color red
|
||||
|
||||
# Enable bottom citations for ordinary external links
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --cite
|
||||
|
||||
# Keep the first heading in content
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --keep-title
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --keep-title
|
||||
|
||||
# Override title
|
||||
npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
||||
${BUN_X} {baseDir}/scripts/main.ts article.md --title "My Article"
|
||||
```
|
||||
|
||||
## Output
|
||||
@@ -168,11 +226,10 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `default` | 经典主题 - 传统排版,标题居中带底边,二级标题白字彩底 |
|
||||
| `grace` | 优雅主题 - 文字阴影,圆角卡片,精致引用块 (by @brzhang) |
|
||||
| `simple` | 简洁主题 - 现代极简风,不对称圆角,清爽留白 (by @okooo5km) |
|
||||
| `red` | 红色主题 - 红金配色,宋体排版,传统书法风格 |
|
||||
| `orange` | 橙色主题 - 暖色调现代风,宽松行距,圆角装饰 |
|
||||
| `default` | Classic - traditional layout, centered title with bottom border, H2 with white text on colored background |
|
||||
| `grace` | Elegant - text shadow, rounded cards, refined blockquotes (by @brzhang) |
|
||||
| `simple` | Minimal - modern minimalist, asymmetric rounded corners, clean whitespace (by @okooo5km) |
|
||||
| `modern` | Modern - large radius, pill-shaped titles, relaxed line height (pair with `--color red` for traditional red-gold style) |
|
||||
|
||||
## Supported Markdown Features
|
||||
|
||||
@@ -184,7 +241,7 @@ npx -y bun ${SKILL_DIR}/scripts/main.ts article.md --title "My Article"
|
||||
| Inline code | `` `code` `` |
|
||||
| Tables | GitHub-flavored markdown tables |
|
||||
| Images | `` |
|
||||
| Links | `[text](url)` with footnote references |
|
||||
| Links | `[text](url)`; add `--cite` to move ordinary external links into bottom references |
|
||||
| Blockquotes | `> quote` |
|
||||
| Lists | `-` unordered, `1.` ordered |
|
||||
| Alerts | `> [!NOTE]`, `> [!WARNING]`, etc. |
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import type { StyleConfig, HtmlDocumentMeta } from './md/types.js';
|
||||
import { DEFAULT_STYLE, THEME_STYLE_DEFAULTS } from './md/constants.js';
|
||||
import { loadThemeCss, normalizeThemeCss } from './md/themes.js';
|
||||
import { initRenderer, renderMarkdown, postProcessHtml } from './md/renderer.js';
|
||||
import {
|
||||
buildCss, loadCodeThemeCss, buildHtmlDocument,
|
||||
inlineCss, normalizeInlineCss, modifyHtmlStructure, removeFirstHeading,
|
||||
} from './md/html-builder.js';
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
@@ -118,11 +122,15 @@ function parseFrontmatter(content: string): { frontmatter: Record<string, string
|
||||
return { frontmatter, body: match[2]! };
|
||||
}
|
||||
|
||||
export async function convertMarkdown(markdownPath: string, options?: { title?: string; theme?: string; keepTitle?: boolean }): Promise<ParsedResult> {
|
||||
export async function convertMarkdown(
|
||||
markdownPath: string,
|
||||
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean }
|
||||
): Promise<ParsedResult> {
|
||||
const baseDir = path.dirname(markdownPath);
|
||||
const content = fs.readFileSync(markdownPath, 'utf-8');
|
||||
const theme = options?.theme ?? 'default';
|
||||
const keepTitle = options?.keepTitle ?? false;
|
||||
const citeStatus = options?.citeStatus ?? false;
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(content);
|
||||
|
||||
@@ -187,33 +195,23 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
|
||||
const modifiedMarkdown = `---\n${Object.entries(frontmatter).map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${modifiedBody}`;
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markdown-to-html-'));
|
||||
const tempMdPath = path.join(tempDir, 'temp-article.md');
|
||||
await writeFile(tempMdPath, modifiedMarkdown, 'utf-8');
|
||||
console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`);
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const renderScript = path.join(__dirname, 'md', 'render.ts');
|
||||
const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
|
||||
const style: StyleConfig = { ...DEFAULT_STYLE, ...themeDefaults };
|
||||
const { baseCss, themeCss } = loadThemeCss(theme);
|
||||
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
|
||||
const codeThemeCss = loadCodeThemeCss('github');
|
||||
|
||||
console.error(`[markdown-to-html] Rendering with theme: ${theme}, keepTitle: ${keepTitle}`);
|
||||
const renderer = initRenderer({ citeStatus });
|
||||
const { html: baseHtml, readingTime } = renderMarkdown(modifiedMarkdown, renderer);
|
||||
let htmlContent = postProcessHtml(baseHtml, readingTime, renderer);
|
||||
if (!keepTitle) htmlContent = removeFirstHeading(htmlContent);
|
||||
|
||||
const args = ['-y', 'bun', renderScript, tempMdPath, '--theme', theme];
|
||||
if (keepTitle) args.push('--keep-title');
|
||||
|
||||
const result = spawnSync('npx', args, {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
cwd: baseDir,
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.toString() || '';
|
||||
throw new Error(`Render failed: ${stderr}`);
|
||||
}
|
||||
|
||||
const tempHtmlPath = tempMdPath.replace(/\.md$/i, '.html');
|
||||
if (!fs.existsSync(tempHtmlPath)) {
|
||||
throw new Error(`HTML file not generated: ${tempHtmlPath}`);
|
||||
}
|
||||
const meta: HtmlDocumentMeta = { title, author, description: summary };
|
||||
const fullHtml = buildHtmlDocument(meta, css, htmlContent, codeThemeCss);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(fullHtml), style);
|
||||
const renderedHtml = modifyHtmlStructure(inlinedHtml);
|
||||
|
||||
const finalHtmlPath = markdownPath.replace(/\.md$/i, '.html');
|
||||
let backupPath: string | undefined;
|
||||
@@ -224,11 +222,16 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
fs.renameSync(finalHtmlPath, backupPath);
|
||||
}
|
||||
|
||||
fs.copyFileSync(tempHtmlPath, finalHtmlPath);
|
||||
fs.writeFileSync(finalHtmlPath, renderedHtml, 'utf-8');
|
||||
|
||||
const contentImages: ImageInfo[] = [];
|
||||
let tempDir: string | undefined;
|
||||
for (const img of images) {
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir);
|
||||
if (!tempDir && (img.src.startsWith('http://') || img.src.startsWith('https://'))) {
|
||||
const os = await import('node:os');
|
||||
tempDir = fs.mkdtempSync(path.join(os.default.tmpdir(), 'markdown-to-html-'));
|
||||
}
|
||||
const localPath = await resolveImagePath(img.src, baseDir, tempDir ?? baseDir);
|
||||
contentImages.push({
|
||||
placeholder: img.placeholder,
|
||||
localPath,
|
||||
@@ -236,12 +239,12 @@ export async function convertMarkdown(markdownPath: string, options?: { title?:
|
||||
});
|
||||
}
|
||||
|
||||
let htmlContent = fs.readFileSync(finalHtmlPath, 'utf-8');
|
||||
let finalContent = fs.readFileSync(finalHtmlPath, 'utf-8');
|
||||
for (const img of contentImages) {
|
||||
const imgTag = `<img src="${img.placeholder}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
htmlContent = htmlContent.replace(img.placeholder, imgTag);
|
||||
const imgTag = `<img src="${img.originalPath}" data-local-path="${img.localPath}" style="display: block; width: 100%; margin: 1.5em auto;">`;
|
||||
finalContent = finalContent.replace(img.placeholder, imgTag);
|
||||
}
|
||||
fs.writeFileSync(finalHtmlPath, htmlContent, 'utf-8');
|
||||
fs.writeFileSync(finalHtmlPath, finalContent, 'utf-8');
|
||||
|
||||
console.error(`[markdown-to-html] HTML saved to: ${finalHtmlPath}`);
|
||||
|
||||
@@ -264,6 +267,7 @@ Usage:
|
||||
Options:
|
||||
--title <title> Override title
|
||||
--theme <name> Theme name (default, grace, simple). Default: default
|
||||
--cite Convert ordinary external links to bottom citations. Default: off
|
||||
--keep-title Keep the first heading in content. Default: false (removed)
|
||||
--help Show this help
|
||||
|
||||
@@ -285,6 +289,7 @@ Output JSON format:
|
||||
Example:
|
||||
npx -y bun main.ts article.md
|
||||
npx -y bun main.ts article.md --theme grace
|
||||
npx -y bun main.ts article.md --cite
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -298,6 +303,7 @@ async function main(): Promise<void> {
|
||||
let markdownPath: string | undefined;
|
||||
let title: string | undefined;
|
||||
let theme: string | undefined;
|
||||
let citeStatus = false;
|
||||
let keepTitle = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
@@ -306,6 +312,8 @@ async function main(): Promise<void> {
|
||||
title = args[++i];
|
||||
} else if (arg === '--theme' && args[i + 1]) {
|
||||
theme = args[++i];
|
||||
} else if (arg === '--cite') {
|
||||
citeStatus = true;
|
||||
} else if (arg === '--keep-title') {
|
||||
keepTitle = true;
|
||||
} else if (!arg.startsWith('-')) {
|
||||
@@ -323,7 +331,7 @@ async function main(): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle });
|
||||
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle, citeStatus });
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { CliOptions, ThemeName } from "./types.js";
|
||||
import {
|
||||
FONT_FAMILY_MAP,
|
||||
FONT_SIZE_OPTIONS,
|
||||
COLOR_PRESETS,
|
||||
CODE_BLOCK_THEMES,
|
||||
} from "./constants.js";
|
||||
import { THEME_NAMES } from "./themes.js";
|
||||
import { loadExtendConfig } from "./extend-config.js";
|
||||
|
||||
export function printUsage(): void {
|
||||
console.error(
|
||||
[
|
||||
"Usage:",
|
||||
" npx tsx src/md/render.ts <markdown_file> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
|
||||
` --color <name|hex> Primary color: ${Object.keys(COLOR_PRESETS).join(", ")}, or hex`,
|
||||
` --font-family <name> Font: ${Object.keys(FONT_FAMILY_MAP).join(", ")}, or CSS value`,
|
||||
` --font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)`,
|
||||
` --code-theme <name> Code highlight theme (default: github)`,
|
||||
` --mac-code-block Show Mac-style code block header`,
|
||||
` --line-number Show line numbers in code blocks`,
|
||||
` --cite Enable footnote citations`,
|
||||
` --count Show reading time / word count`,
|
||||
` --legend <value> Image caption: title-alt, alt-title, title, alt, none`,
|
||||
` --keep-title Keep the first heading in output`,
|
||||
].join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgValue(argv: string[], i: number, flag: string): string | null {
|
||||
const arg = argv[i]!;
|
||||
if (arg.includes("=")) {
|
||||
return arg.slice(flag.length + 1);
|
||||
}
|
||||
const next = argv[i + 1];
|
||||
return next ?? null;
|
||||
}
|
||||
|
||||
function resolveFontFamily(value: string): string {
|
||||
return FONT_FAMILY_MAP[value] ?? value;
|
||||
}
|
||||
|
||||
function resolveColor(value: string): string {
|
||||
return COLOR_PRESETS[value] ?? value;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliOptions | null {
|
||||
const ext = loadExtendConfig();
|
||||
|
||||
let inputPath = "";
|
||||
let theme: ThemeName = ext.default_theme ?? "default";
|
||||
let keepTitle = ext.keep_title ?? false;
|
||||
let primaryColor: string | undefined = ext.default_color ? resolveColor(ext.default_color) : undefined;
|
||||
let fontFamily: string | undefined = ext.default_font_family ? resolveFontFamily(ext.default_font_family) : undefined;
|
||||
let fontSize: string | undefined = ext.default_font_size ?? undefined;
|
||||
let codeTheme = ext.default_code_theme ?? "github";
|
||||
let isMacCodeBlock = ext.mac_code_block ?? true;
|
||||
let isShowLineNumber = ext.show_line_number ?? false;
|
||||
let citeStatus = ext.cite ?? false;
|
||||
let countStatus = ext.count ?? false;
|
||||
let legend = ext.legend ?? "alt";
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i]!;
|
||||
|
||||
if (!arg.startsWith("--") && !inputPath) {
|
||||
inputPath = arg;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (arg === "--keep-title") { keepTitle = true; continue; }
|
||||
if (arg === "--mac-code-block") { isMacCodeBlock = true; continue; }
|
||||
if (arg === "--no-mac-code-block") { isMacCodeBlock = false; continue; }
|
||||
if (arg === "--line-number") { isShowLineNumber = true; continue; }
|
||||
if (arg === "--cite") { citeStatus = true; continue; }
|
||||
if (arg === "--count") { countStatus = true; continue; }
|
||||
|
||||
if (arg === "--theme" || arg.startsWith("--theme=")) {
|
||||
const val = parseArgValue(argv, i, "--theme");
|
||||
if (!val) { console.error("Missing value for --theme"); return null; }
|
||||
theme = val as ThemeName;
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--color" || arg.startsWith("--color=")) {
|
||||
const val = parseArgValue(argv, i, "--color");
|
||||
if (!val) { console.error("Missing value for --color"); return null; }
|
||||
primaryColor = resolveColor(val);
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--font-family" || arg.startsWith("--font-family=")) {
|
||||
const val = parseArgValue(argv, i, "--font-family");
|
||||
if (!val) { console.error("Missing value for --font-family"); return null; }
|
||||
fontFamily = resolveFontFamily(val);
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--font-size" || arg.startsWith("--font-size=")) {
|
||||
const val = parseArgValue(argv, i, "--font-size");
|
||||
if (!val) { console.error("Missing value for --font-size"); return null; }
|
||||
fontSize = val.endsWith("px") ? val : `${val}px`;
|
||||
if (!FONT_SIZE_OPTIONS.includes(fontSize)) {
|
||||
console.error(`Invalid font size: ${fontSize}. Valid: ${FONT_SIZE_OPTIONS.join(", ")}`);
|
||||
return null;
|
||||
}
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--code-theme" || arg.startsWith("--code-theme=")) {
|
||||
const val = parseArgValue(argv, i, "--code-theme");
|
||||
if (!val) { console.error("Missing value for --code-theme"); return null; }
|
||||
codeTheme = val;
|
||||
if (!CODE_BLOCK_THEMES.includes(codeTheme)) {
|
||||
console.error(`Unknown code theme: ${codeTheme}`);
|
||||
return null;
|
||||
}
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--legend" || arg.startsWith("--legend=")) {
|
||||
const val = parseArgValue(argv, i, "--legend");
|
||||
if (!val) { console.error("Missing value for --legend"); return null; }
|
||||
const valid = ["title-alt", "alt-title", "title", "alt", "none"];
|
||||
if (!valid.includes(val)) {
|
||||
console.error(`Invalid legend: ${val}. Valid: ${valid.join(", ")}`);
|
||||
return null;
|
||||
}
|
||||
legend = val;
|
||||
if (!arg.includes("=")) i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(`Unknown argument: ${arg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!inputPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!THEME_NAMES.includes(theme)) {
|
||||
console.error(`Unknown theme: ${theme}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
inputPath, theme, keepTitle, primaryColor, fontFamily, fontSize,
|
||||
codeTheme, isMacCodeBlock, isShowLineNumber, citeStatus, countStatus, legend,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: 1c-light
|
||||
Description: Style IDE 1C:Enterprise 8
|
||||
Author: (c) Barilko Vitaliy <barilkovetal@gmail.com>
|
||||
Maintainer: @Diversus23
|
||||
Website: https://softonit.ru/
|
||||
License: see project LICENSE
|
||||
Touched: 2023
|
||||
*/.hljs{color:#00f;background:#fff}.hljs-comment{color:green}.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-function,.hljs-keyword,.hljs-name,.hljs-punctuation,.hljs-selector-tag{color:red}.hljs-params,.hljs-type{color:#00f}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-symbol,.hljs-template-tag{color:#000}.hljs-section,.hljs-title{color:#00f}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:red}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#00f}.hljs-meta,.hljs-meta .hljs-keyword,.hljs-meta .hljs-string{color:#963200}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1,7 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: a11y-dark
|
||||
Author: @ericwbailey
|
||||
Maintainer: @ericwbailey
|
||||
|
||||
Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css
|
||||
*/.hljs{background:#2b2b2b;color:#f8f8f2}.hljs-comment,.hljs-quote{color:#d4d0ab}.hljs-deletion,.hljs-name,.hljs-regexp,.hljs-selector-class,.hljs-selector-id,.hljs-tag,.hljs-template-variable,.hljs-variable{color:#ffa07a}.hljs-built_in,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-type{color:#f5ab35}.hljs-attribute{color:gold}.hljs-addition,.hljs-bullet,.hljs-string,.hljs-symbol{color:#abe338}.hljs-section,.hljs-title{color:#00e0e0}.hljs-keyword,.hljs-selector-tag{color:#dcc6e0}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}@media screen and (-ms-high-contrast:active){.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-comment,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-quote,.hljs-string,.hljs-symbol,.hljs-type{color:highlight}.hljs-keyword,.hljs-selector-tag{font-weight:700}}
|
||||
@@ -0,0 +1,7 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: a11y-light
|
||||
Author: @ericwbailey
|
||||
Maintainer: @ericwbailey
|
||||
|
||||
Based on the Tomorrow Night Eighties theme: https://github.com/isagalaev/highlight.js/blob/master/src/styles/tomorrow-night-eighties.css
|
||||
*/.hljs{background:#fefefe;color:#545454}.hljs-comment,.hljs-quote{color:#696969}.hljs-deletion,.hljs-name,.hljs-regexp,.hljs-selector-class,.hljs-selector-id,.hljs-tag,.hljs-template-variable,.hljs-variable{color:#d91e18}.hljs-attribute,.hljs-built_in,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-type{color:#aa5d00}.hljs-addition,.hljs-bullet,.hljs-string,.hljs-symbol{color:green}.hljs-section,.hljs-title{color:#007faa}.hljs-keyword,.hljs-selector-tag{color:#7928a1}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}@media screen and (-ms-high-contrast:active){.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-comment,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-quote,.hljs-string,.hljs-symbol,.hljs-type{color:highlight}.hljs-keyword,.hljs-selector-tag{font-weight:700}}
|
||||
@@ -0,0 +1,20 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: Agate
|
||||
Author: (c) Taufik Nurrohman <hi@taufik-nurrohman.com>
|
||||
Maintainer: @taufik-nurrohman
|
||||
Updated: 2021-04-24
|
||||
|
||||
#333
|
||||
#62c8f3
|
||||
#7bd694
|
||||
#888
|
||||
#a2fca2
|
||||
#ade5fc
|
||||
#b8d8a2
|
||||
#c6b4f0
|
||||
#d36363
|
||||
#fc9b9b
|
||||
#fcc28c
|
||||
#ffa
|
||||
#fff
|
||||
*/.hljs{background:#333;color:#fff}.hljs-doctag,.hljs-meta-keyword,.hljs-name,.hljs-strong{font-weight:700}.hljs-code,.hljs-emphasis{font-style:italic}.hljs-section,.hljs-tag{color:#62c8f3}.hljs-selector-class,.hljs-selector-id,.hljs-template-variable,.hljs-variable{color:#ade5fc}.hljs-meta-string,.hljs-string{color:#a2fca2}.hljs-attr,.hljs-quote,.hljs-selector-attr{color:#7bd694}.hljs-tag .hljs-attr{color:inherit}.hljs-attribute,.hljs-title,.hljs-type{color:#ffa}.hljs-number,.hljs-symbol{color:#d36363}.hljs-bullet,.hljs-template-tag{color:#b8d8a2}.hljs-built_in,.hljs-keyword,.hljs-literal,.hljs-selector-tag{color:#fcc28c}.hljs-code,.hljs-comment,.hljs-formula{color:#888}.hljs-link,.hljs-regexp,.hljs-selector-pseudo{color:#c6b4f0}.hljs-meta{color:#fc9b9b}.hljs-deletion{background:#fc9b9b;color:#333}.hljs-addition{background:#a2fca2;color:#333}.hljs-subst{color:#fff}.hljs a{color:inherit}.hljs a:focus,.hljs a:hover{color:inherit;text-decoration:underline}.hljs mark{background:#555;color:inherit}
|
||||
@@ -0,0 +1,9 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: An Old Hope – Star Wars Syntax
|
||||
Author: (c) Gustavo Costa <gusbemacbe@gmail.com>
|
||||
Maintainer: @gusbemacbe
|
||||
|
||||
Original theme - Ocean Dark Theme – by https://github.com/gavsiu
|
||||
Based on Jesse Leite's Atom syntax theme 'An Old Hope'
|
||||
https://github.com/JesseLeite/an-old-hope-syntax-atom
|
||||
*/.hljs{background:#1c1d21;color:#c0c5ce}.hljs-comment,.hljs-quote{color:#b6b18b}.hljs-deletion,.hljs-name,.hljs-regexp,.hljs-selector-class,.hljs-selector-id,.hljs-tag,.hljs-template-variable,.hljs-variable{color:#eb3c54}.hljs-built_in,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-type{color:#e7ce56}.hljs-attribute{color:#ee7c2b}.hljs-addition,.hljs-bullet,.hljs-string,.hljs-symbol{color:#4fb4d7}.hljs-section,.hljs-title{color:#78bb65}.hljs-keyword,.hljs-selector-tag{color:#b45ea4}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#a9b7c6;background:#282b2e}.hljs-bullet,.hljs-literal,.hljs-number,.hljs-symbol{color:#6897bb}.hljs-deletion,.hljs-keyword,.hljs-selector-tag{color:#cc7832}.hljs-link,.hljs-template-variable,.hljs-variable{color:#629755}.hljs-comment,.hljs-quote{color:grey}.hljs-meta{color:#bbb529}.hljs-addition,.hljs-attribute,.hljs-string{color:#6a8759}.hljs-section,.hljs-title,.hljs-type{color:#ffc66d}.hljs-name,.hljs-selector-class,.hljs-selector-id{color:#e8bf6a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#fff;color:#434f54}.hljs-subst{color:#434f54}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-name,.hljs-selector-tag{color:#00979d}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code,.hljs-literal{color:#d35400}.hljs-link,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#00979d}.hljs-deletion,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#005c5f}.hljs-comment{color:rgba(149,165,166,.8)}.hljs-meta .hljs-keyword{color:#728e00}.hljs-meta{color:#434f54}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-function{color:#728e00}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-number{color:#8a7b52}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#222;color:#aaa}.hljs-subst{color:#aaa}.hljs-section{color:#fff}.hljs-comment,.hljs-meta,.hljs-quote{color:#444}.hljs-bullet,.hljs-regexp,.hljs-string,.hljs-symbol{color:#fc3}.hljs-addition,.hljs-number{color:#0c6}.hljs-attribute,.hljs-built_in,.hljs-link,.hljs-literal,.hljs-template-variable,.hljs-type{color:#32aaee}.hljs-keyword,.hljs-name,.hljs-selector-class,.hljs-selector-id,.hljs-selector-tag{color:#64a}.hljs-deletion,.hljs-template-tag,.hljs-title,.hljs-variable{color:#b16}.hljs-doctag,.hljs-section,.hljs-strong{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#fff;color:#000}.hljs-addition,.hljs-attribute,.hljs-bullet,.hljs-link,.hljs-section,.hljs-string,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#888}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#ccc}.hljs-keyword,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
+1
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-keyword,.hljs-operator,.hljs-pattern-match{color:#f92672}.hljs-function,.hljs-pattern-match .hljs-constructor{color:#61aeee}.hljs-function .hljs-params{color:#a6e22e}.hljs-function .hljs-params .hljs-typing{color:#fd971f}.hljs-module-access .hljs-module{color:#7e57c2}.hljs-constructor{color:#e2b93d}.hljs-constructor .hljs-string{color:#9ccc65}.hljs-comment,.hljs-quote{color:#b18eb1;font-style:italic}.hljs-doctag,.hljs-formula{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#363c69;background:url(./brown-papersq.png) #b7a68e}.hljs-keyword,.hljs-literal,.hljs-selector-tag{color:#059}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-link,.hljs-name,.hljs-section,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#2c009f}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#802022}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#222;color:#fff}.hljs-comment,.hljs-quote{color:#777}.hljs-built_in,.hljs-bullet,.hljs-deletion,.hljs-link,.hljs-literal,.hljs-meta,.hljs-number,.hljs-params,.hljs-regexp,.hljs-symbol,.hljs-tag,.hljs-template-variable,.hljs-variable{color:#ab875d}.hljs-attribute,.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-title,.hljs-type{color:#9b869b}.hljs-addition,.hljs-keyword,.hljs-selector-tag,.hljs-string{color:#8f9c6c}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#000;background:#fff}.hljs-addition,.hljs-meta,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable{color:#756bb1}.hljs-comment,.hljs-quote{color:#636363}.hljs-bullet,.hljs-link,.hljs-literal,.hljs-number,.hljs-regexp{color:#31a354}.hljs-deletion,.hljs-variable{color:#88f}.hljs-built_in,.hljs-doctag,.hljs-keyword,.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-selector-tag,.hljs-strong,.hljs-tag,.hljs-title,.hljs-type{color:#3182bd}.hljs-emphasis{font-style:italic}.hljs-attribute{color:#e6550d}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#ddd;background:#303030}.hljs-keyword,.hljs-link,.hljs-literal,.hljs-section,.hljs-selector-tag{color:#fff}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#d88}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#979797}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
@@ -0,0 +1,9 @@
|
||||
/*!
|
||||
Theme: Default
|
||||
Description: Original highlight.js style
|
||||
Author: (c) Ivan Sagalaev <maniac@softwaremaniacs.org>
|
||||
Maintainer: @highlightjs/core-team
|
||||
Website: https://highlightjs.org/
|
||||
License: see project LICENSE
|
||||
Touched: 2021
|
||||
*/pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1,7 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: devibeans (dark)
|
||||
Author: @terminaldweller
|
||||
Maintainer: @terminaldweller
|
||||
|
||||
Inspired by vim's jellybeans theme (https://github.com/nanotech/jellybeans.vim)
|
||||
*/.hljs{background:#000;color:#a39e9b}.hljs-attr,.hljs-template-tag{color:#8787d7}.hljs-comment,.hljs-doctag,.hljs-quote{color:#396}.hljs-params{color:#a39e9b}.hljs-regexp{color:#d700ff}.hljs-literal,.hljs-number,.hljs-selector-id,.hljs-tag{color:#ef5350}.hljs-meta,.hljs-meta .hljs-keyword{color:#0087ff}.hljs-code,.hljs-formula,.hljs-keyword,.hljs-link,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-variable{color:#64b5f6}.hljs-built_in,.hljs-deletion,.hljs-title{color:#ff8700}.hljs-attribute,.hljs-function,.hljs-name,.hljs-property,.hljs-section,.hljs-type{color:#ffd75f}.hljs-addition,.hljs-bullet,.hljs-meta .hljs-string,.hljs-string,.hljs-subst,.hljs-symbol{color:#558b2f}.hljs-selector-tag{color:#96f}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#000;background:#f8f8ff}.hljs-comment,.hljs-quote{color:#408080;font-style:italic}.hljs-keyword,.hljs-literal,.hljs-selector-tag,.hljs-subst{color:#954121}.hljs-number{color:#40a070}.hljs-doctag,.hljs-string{color:#219161}.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-type{color:#19469d}.hljs-params{color:#00f}.hljs-title{color:#458;font-weight:700}.hljs-attribute,.hljs-name,.hljs-tag{color:navy;font-weight:400}.hljs-template-variable,.hljs-variable{color:teal}.hljs-link,.hljs-regexp{color:#b68}.hljs-bullet,.hljs-symbol{color:#990073}.hljs-built_in{color:#0086b3}.hljs-meta{color:#999;font-weight:700}.hljs-deletion{background:#fdd}.hljs-addition{background:#dfd}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#0ff;background:navy}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable{color:#ff0}.hljs-keyword,.hljs-name,.hljs-section,.hljs-selector-class,.hljs-selector-id,.hljs-selector-tag,.hljs-type,.hljs-variable{color:#fff}.hljs-comment,.hljs-deletion,.hljs-doctag,.hljs-quote{color:#888}.hljs-link,.hljs-literal,.hljs-number,.hljs-regexp{color:#0f0}.hljs-meta{color:teal}.hljs-keyword,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
@@ -0,0 +1,7 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
* Theme: FelipeC
|
||||
* Author: (c) 2021 Felipe Contreras <felipe.contreras@gmail.com>
|
||||
* Website: https://github.com/felipec/vim-felipec
|
||||
*
|
||||
* Autogenerated with vim-felipec's generator.
|
||||
*/.hljs{color:#dedde4;background-color:#1d1c21}.hljs ::selection,.hljs::selection{color:#1d1c21;background-color:#ba9cef}.hljs-code,.hljs-comment,.hljs-quote{color:#9e9da4}.hljs-deletion,.hljs-literal,.hljs-number{color:#f09080}.hljs-doctag,.hljs-meta,.hljs-operator,.hljs-punctuation,.hljs-selector-attr,.hljs-subst,.hljs-template-variable{color:#ffbb7b}.hljs-type{color:#fddb7c}.hljs-selector-class,.hljs-selector-id,.hljs-tag,.hljs-title{color:#c4da7d}.hljs-addition,.hljs-regexp,.hljs-string{color:#93e4a4}.hljs-class,.hljs-property{color:#65e7d1}.hljs-name,.hljs-selector-tag{color:#30c2d8}.hljs-built_in,.hljs-keyword{color:#5fb8f2}.hljs-bullet,.hljs-section{color:#90aafa}.hljs-selector-pseudo{color:#ba9cef}.hljs-attr,.hljs-attribute,.hljs-params,.hljs-variable{color:#d991d2}.hljs-link,.hljs-symbol{color:#ec8dab}.hljs-literal,.hljs-strong,.hljs-title{font-weight:700}.hljs-emphasis{font-style:italic}
|
||||
@@ -0,0 +1 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#eee;color:#000}.hljs-addition,.hljs-attribute,.hljs-emphasis,.hljs-link{color:#070}.hljs-emphasis{font-style:italic}.hljs-deletion,.hljs-string,.hljs-strong{color:#d14}.hljs-strong{font-weight:700}.hljs-comment,.hljs-quote{color:#998;font-style:italic}.hljs-section,.hljs-title{color:#900}.hljs-class .hljs-title,.hljs-title.class_,.hljs-type{color:#458}.hljs-template-variable,.hljs-variable{color:#369}.hljs-bullet{color:#970}.hljs-meta{color:#34b}.hljs-code,.hljs-keyword,.hljs-literal,.hljs-number,.hljs-selector-tag{color:#099}.hljs-regexp{background-color:#fff0ff;color:#808}.hljs-symbol{color:#990073}.hljs-name,.hljs-selector-class,.hljs-selector-id,.hljs-tag{color:#070}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark Dimmed
|
||||
Description: Dark dimmed theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Colors taken from GitHub's CSS
|
||||
*/.hljs{color:#adbac7;background:#22272e}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#f47067}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#dcbdfb}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#6cb6ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#96d0ff}.hljs-built_in,.hljs-symbol{color:#f69d50}.hljs-code,.hljs-comment,.hljs-formula{color:#768390}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#8ddb8c}.hljs-subst{color:#adbac7}.hljs-section{color:#316dca;font-weight:700}.hljs-bullet{color:#eac55f}.hljs-emphasis{color:#adbac7;font-style:italic}.hljs-strong{color:#adbac7;font-weight:700}.hljs-addition{color:#b4f1b4;background-color:#1b4721}.hljs-deletion{color:#ffd8d3;background-color:#78191b}
|
||||
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
||||
@@ -0,0 +1,10 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user