From 97bc68efd8a158450d261aa79850a7f3a9d4f62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Sun, 18 Jan 2026 20:11:49 -0600 Subject: [PATCH] chore: release v1.1.0 --- .claude-plugin/marketplace.json | 25 +- CHANGELOG.md | 11 + CHANGELOG.zh.md | 11 + CLAUDE.md | 52 +++- README.md | 130 +++++--- README.zh.md | 130 +++++--- skills/baoyu-compress-image/SKILL.md | 188 ++++++++++++ skills/baoyu-compress-image/scripts/main.ts | 315 ++++++++++++++++++++ 8 files changed, 766 insertions(+), 96 deletions(-) create mode 100644 skills/baoyu-compress-image/SKILL.md create mode 100644 skills/baoyu-compress-image/scripts/main.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8a97b4a..503d5c3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,17 +6,15 @@ }, "metadata": { "description": "Skills shared by Baoyu for improving daily work efficiency", - "version": "1.0.1" + "version": "1.1.0" }, "plugins": [ { "name": "content-skills", - "description": "Skills shared by Baoyu for improving daily work efficiency", + "description": "Content generation and publishing skills", "source": "./", "strict": false, "skills": [ - "./skills/baoyu-danger-gemini-web", - "./skills/baoyu-danger-x-to-markdown", "./skills/baoyu-xhs-images", "./skills/baoyu-post-to-x", "./skills/baoyu-post-to-wechat", @@ -25,6 +23,25 @@ "./skills/baoyu-slide-deck", "./skills/baoyu-comic" ] + }, + { + "name": "ai-generation-skills", + "description": "AI-powered generation backends", + "source": "./", + "strict": false, + "skills": [ + "./skills/baoyu-danger-gemini-web" + ] + }, + { + "name": "utility-skills", + "description": "Utility tools for content processing", + "source": "./", + "strict": false, + "skills": [ + "./skills/baoyu-danger-x-to-markdown", + "./skills/baoyu-compress-image" + ] } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b4bf4a..b38d8fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ English | [中文](./CHANGELOG.zh.md) +## 1.1.0 - 2026-01-18 + +### Features +- `baoyu-compress-image`: new utility skill for cross-platform image compression. Converts to WebP by default with PNG-to-PNG support. Uses system tools (sips, cwebp, ImageMagick) with Sharp fallback. + +### Refactor +- Marketplace structure: reorganizes plugins into three categories—`content-skills`, `ai-generation-skills`, and `utility-skills`—for better organization. + +### Documentation +- `CLAUDE.md`, `README.md`, `README.zh.md`: updates skill architecture documentation to reflect the new three-category structure. + ## 1.0.1 - 2026-01-18 ### Refactor diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index 8a4fed8..a8b2c2e 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -2,6 +2,17 @@ [English](./CHANGELOG.md) | 中文 +## 1.1.0 - 2026-01-18 + +### 新功能 +- `baoyu-compress-image`:新增跨平台图片压缩技能。默认转换为 WebP 格式,支持 PNG 转 PNG。自动选择系统工具(sips、cwebp、ImageMagick),Sharp 作为兜底方案。 + +### 重构 +- Marketplace 结构重组:将插件分为三大类——`content-skills`(内容技能)、`ai-generation-skills`(AI 生成技能)和 `utility-skills`(工具技能),便于管理和发现。 + +### 文档 +- `CLAUDE.md`、`README.md`、`README.zh.md`:更新技能架构文档,反映新的三类分组结构。 + ## 1.0.1 - 2026-01-18 ### 重构 diff --git a/CLAUDE.md b/CLAUDE.md index 5890b39..e95ccca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,34 @@ Claude Code marketplace plugin providing AI-powered content generation skills. S ## Architecture +Skills are organized into three plugin categories in `marketplace.json`: + ``` skills/ -├── baoyu-danger-gemini-web/ # Core: Gemini API wrapper (text + image gen) -├── 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-post-to-x/ # X/Twitter posting automation -└── baoyu-post-to-wechat/ # WeChat Official Account posting +├── [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) | + Each skill contains: - `SKILL.md` - YAML front matter (name, description) + documentation - `scripts/` - TypeScript implementations @@ -70,9 +87,28 @@ npx -y bun skills/baoyu-danger-gemini-web/scripts/main.ts --promptfiles system.m - SKILL.md `name` field: `baoyu-` 2. Add TypeScript in `skills/baoyu-/scripts/` 3. Add prompt templates in `skills/baoyu-/prompts/` if needed -4. Register in `marketplace.json` plugins[0].skills array as `./skills/baoyu-` +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 + ### Script Directory Template Every SKILL.md with scripts MUST include this section after Usage: diff --git a/README.md b/README.md index b9d760e..d148169 100644 --- a/README.md +++ b/README.md @@ -61,47 +61,13 @@ You can also **Enable auto-update** to get the latest versions automatically. ## Available Skills -### baoyu-danger-gemini-web +Skills are organized into three categories: -Interacts with Gemini Web to generate text and images. +### Content Skills -**Text Generation:** +Content generation and publishing skills. -```bash -/baoyu-danger-gemini-web "Hello, Gemini" -/baoyu-danger-gemini-web --prompt "Explain quantum computing" -``` - -**Image Generation:** - -```bash -/baoyu-danger-gemini-web --prompt "A cute cat" --image cat.png -/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png -``` - -### baoyu-danger-x-to-markdown - -Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles. - -```bash -# Convert tweet to markdown -/baoyu-danger-x-to-markdown https://x.com/username/status/123456 - -# Save to specific file -/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md - -# JSON output -/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json -``` - -**Supported URLs:** -- `https://x.com//status/` -- `https://twitter.com//status/` -- `https://x.com/i/article/` - -**Authentication:** Uses environment variables (`X_AUTH_TOKEN`, `X_CT0`) or Chrome login for cookie-based auth. - -### baoyu-xhs-images +#### baoyu-xhs-images Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-10 cartoon-style infographics with **Style × Layout** two-dimensional system. @@ -134,7 +100,7 @@ Xiaohongshu (RedNote) infographic series generator. Breaks down content into 1-1 | `comparison` | 2 sides | Before/after, pros/cons | | `flow` | 3-6 steps | Processes, timelines | -### baoyu-cover-image +#### baoyu-cover-image Generate hand-drawn style cover images for articles with multiple style options. @@ -152,7 +118,7 @@ Generate hand-drawn style cover images for articles with multiple style options. Available styles: `elegant` (default), `tech`, `warm`, `bold`, `minimal`, `playful`, `nature`, `retro` -### baoyu-slide-deck +#### baoyu-slide-deck Generate professional slide deck images from content. Creates comprehensive outlines with style instructions, then generates individual slide images. @@ -208,7 +174,7 @@ Generate professional slide deck images from content. Creates comprehensive outl After generation, slides are automatically merged into a `.pptx` file for easy sharing. -### baoyu-comic +#### baoyu-comic Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohmsha manga guide). Creates original educational comics with detailed panel layouts and sequential image generation. @@ -265,7 +231,30 @@ Knowledge comic creator supporting multiple styles (Logicomix/Ligne Claire, Ohms | `mixed` | 3-7 varies | Complex narratives, emotional arcs | | `webtoon` | 3-5 vertical | Ohmsha tutorials, mobile reading | -### baoyu-post-to-wechat +#### baoyu-article-illustrator + +Smart article illustration skill. Analyzes article content and generates illustrations at positions requiring visual aids. + +```bash +/baoyu-article-illustrator path/to/article.md +``` + +#### baoyu-post-to-x + +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. + +```bash +# Post with text +/baoyu-post-to-x "Hello from Claude Code!" + +# Post with images +/baoyu-post-to-x "Check this out" --image photo.png + +# Post X Article +/baoyu-post-to-x --article path/to/article.md +``` + +#### baoyu-post-to-wechat Post content to WeChat Official Account (微信公众号). Two modes available: @@ -287,6 +276,63 @@ Post content to WeChat Official Account (微信公众号). Two modes available: Prerequisites: Google Chrome installed. First run requires QR code login (session preserved). +### AI Generation Skills + +AI-powered generation backends. + +#### baoyu-danger-gemini-web + +Interacts with Gemini Web to generate text and images. + +**Text Generation:** + +```bash +/baoyu-danger-gemini-web "Hello, Gemini" +/baoyu-danger-gemini-web --prompt "Explain quantum computing" +``` + +**Image Generation:** + +```bash +/baoyu-danger-gemini-web --prompt "A cute cat" --image cat.png +/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png +``` + +### Utility Skills + +Utility tools for content processing. + +#### baoyu-danger-x-to-markdown + +Converts X (Twitter) content to markdown format. Supports tweet threads and X Articles. + +```bash +# Convert tweet to markdown +/baoyu-danger-x-to-markdown https://x.com/username/status/123456 + +# Save to specific file +/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md + +# JSON output +/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json +``` + +**Supported URLs:** +- `https://x.com//status/` +- `https://twitter.com//status/` +- `https://x.com/i/article/` + +**Authentication:** Uses environment variables (`X_AUTH_TOKEN`, `X_CT0`) or Chrome login for cookie-based auth. + +#### baoyu-compress-image + +Compress images to reduce file size while maintaining quality. + +```bash +/baoyu-compress-image path/to/image.png +/baoyu-compress-image path/to/images/ --quality 80 +``` + ## Customization All skills support customization via `EXTEND.md` files. Create an extension file to override default styles, add custom configurations, or define your own presets. diff --git a/README.zh.md b/README.zh.md index 9ffa11e..e19f611 100644 --- a/README.zh.md +++ b/README.zh.md @@ -61,47 +61,13 @@ npx add-skill jimliu/baoyu-skills ## 可用技能 -### baoyu-danger-gemini-web +技能分为三大类: -与 Gemini Web 交互,生成文本和图片。 +### 内容技能 (Content Skills) -**文本生成:** +内容生成和发布技能。 -```bash -/baoyu-danger-gemini-web "你好,Gemini" -/baoyu-danger-gemini-web --prompt "解释量子计算" -``` - -**图片生成:** - -```bash -/baoyu-danger-gemini-web --prompt "一只可爱的猫" --image cat.png -/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png -``` - -### baoyu-danger-x-to-markdown - -将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。 - -```bash -# 将推文转换为 markdown -/baoyu-danger-x-to-markdown https://x.com/username/status/123456 - -# 保存到指定文件 -/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md - -# JSON 输出 -/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json -``` - -**支持的 URL:** -- `https://x.com//status/` -- `https://twitter.com//status/` -- `https://x.com/i/article/` - -**身份验证:** 使用环境变量(`X_AUTH_TOKEN`、`X_CT0`)或 Chrome 登录进行 cookie 认证。 - -### baoyu-xhs-images +#### baoyu-xhs-images 小红书信息图系列生成器。将内容拆解为 1-10 张卡通风格信息图,支持 **风格 × 布局** 二维系统。 @@ -134,7 +100,7 @@ npx add-skill jimliu/baoyu-skills | `comparison` | 双栏 | 对比、优劣 | | `flow` | 3-6 步 | 流程、时间线 | -### baoyu-cover-image +#### baoyu-cover-image 为文章生成手绘风格封面图,支持多种风格选项。 @@ -152,7 +118,7 @@ npx add-skill jimliu/baoyu-skills 可用风格:`elegant`(默认)、`tech`、`warm`、`bold`、`minimal`、`playful`、`nature`、`retro` -### baoyu-slide-deck +#### baoyu-slide-deck 从内容生成专业的幻灯片图片。先创建包含样式说明的完整大纲,然后逐页生成幻灯片图片。 @@ -208,7 +174,7 @@ npx add-skill jimliu/baoyu-skills 生成完成后,所有幻灯片会自动合并为 `.pptx` 文件,方便分享。 -### baoyu-comic +#### baoyu-comic 知识漫画创作器,支持多种风格(Logicomix/清线风格、欧姆社漫画教程风格)。创作带有详细分镜布局的原创教育漫画,逐页生成图片。 @@ -265,7 +231,30 @@ npx add-skill jimliu/baoyu-skills | `mixed` | 3-7 不等 | 复杂叙事、情感弧线 | | `webtoon` | 3-5 竖向 | 欧姆社教程、手机阅读 | -### baoyu-post-to-wechat +#### baoyu-article-illustrator + +智能文章插图技能。分析文章内容,在需要视觉辅助的位置生成插图。 + +```bash +/baoyu-article-illustrator path/to/article.md +``` + +#### baoyu-post-to-x + +发布内容和文章到 X (Twitter)。支持带图片的普通帖子和 X 文章(长篇 Markdown)。使用真实 Chrome + CDP 绕过反自动化检测。 + +```bash +# 发布文字 +/baoyu-post-to-x "Hello from Claude Code!" + +# 发布带图片 +/baoyu-post-to-x "看看这个" --image photo.png + +# 发布 X 文章 +/baoyu-post-to-x --article path/to/article.md +``` + +#### baoyu-post-to-wechat 发布内容到微信公众号,支持两种模式: @@ -287,6 +276,63 @@ npx add-skill jimliu/baoyu-skills 前置要求:已安装 Google Chrome,首次运行需扫码登录(登录状态会保存) +### AI 生成技能 (AI Generation Skills) + +AI 驱动的生成后端。 + +#### baoyu-danger-gemini-web + +与 Gemini Web 交互,生成文本和图片。 + +**文本生成:** + +```bash +/baoyu-danger-gemini-web "你好,Gemini" +/baoyu-danger-gemini-web --prompt "解释量子计算" +``` + +**图片生成:** + +```bash +/baoyu-danger-gemini-web --prompt "一只可爱的猫" --image cat.png +/baoyu-danger-gemini-web --promptfiles system.md content.md --image out.png +``` + +### 工具技能 (Utility Skills) + +内容处理工具。 + +#### baoyu-danger-x-to-markdown + +将 X (Twitter) 内容转换为 markdown 格式。支持推文串和 X 文章。 + +```bash +# 将推文转换为 markdown +/baoyu-danger-x-to-markdown https://x.com/username/status/123456 + +# 保存到指定文件 +/baoyu-danger-x-to-markdown https://x.com/username/status/123456 -o output.md + +# JSON 输出 +/baoyu-danger-x-to-markdown https://x.com/username/status/123456 --json +``` + +**支持的 URL:** +- `https://x.com//status/` +- `https://twitter.com//status/` +- `https://x.com/i/article/` + +**身份验证:** 使用环境变量(`X_AUTH_TOKEN`、`X_CT0`)或 Chrome 登录进行 cookie 认证。 + +#### baoyu-compress-image + +压缩图片以减小文件大小,同时保持质量。 + +```bash +/baoyu-compress-image path/to/image.png +/baoyu-compress-image path/to/images/ --quality 80 +``` + ## 自定义扩展 所有技能支持通过 `EXTEND.md` 文件自定义。创建扩展文件可覆盖默认样式、添加自定义配置或定义个人预设。 diff --git a/skills/baoyu-compress-image/SKILL.md b/skills/baoyu-compress-image/SKILL.md new file mode 100644 index 0000000..8346b29 --- /dev/null +++ b/skills/baoyu-compress-image/SKILL.md @@ -0,0 +1,188 @@ +--- +name: baoyu-compress-image +description: Cross-platform image compression skill. Converts images to WebP by default with PNG-to-PNG support. Uses system tools (sips, cwebp, ImageMagick) with Sharp fallback. +--- + +# Image Compressor + +Cross-platform image compression with WebP default output, PNG-to-PNG support, preferring system tools with Sharp fallback. + +## 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/.ts` +3. Replace all `${SKILL_DIR}` in this document with the actual path + +**Script Reference**: +| Script | Purpose | +|--------|---------| +| `scripts/main.ts` | CLI entry point for image compression | + +## Quick Start + +```bash +# Compress to WebP (default) +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png + +# Keep original format (PNG → PNG) +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --format png + +# Custom quality +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -q 75 + +# Process directory +npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r +``` + +## Commands + +### Single File Compression + +```bash +# Basic (converts to WebP, replaces original) +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png + +# Custom output path +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -o compressed.webp + +# Keep original file +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --keep + +# Custom quality (0-100, default: 80) +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -q 75 + +# Keep original format +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png -f png +``` + +### Directory Processing + +```bash +# Process all images in directory +npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ + +# Recursive processing +npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r + +# With custom quality +npx -y bun ${SKILL_DIR}/scripts/main.ts ./images/ -r -q 75 +``` + +### Output Formats + +```bash +# Plain text (default) +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png + +# JSON output +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json +``` + +## Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `` | | Input file or directory | Required | +| `--output ` | `-o` | Output path | Same path, new extension | +| `--format ` | `-f` | webp, png, jpeg | webp | +| `--quality ` | `-q` | Quality 0-100 | 80 | +| `--keep` | `-k` | Keep original file | false | +| `--recursive` | `-r` | Process directories recursively | false | +| `--json` | | JSON output | false | +| `--help` | `-h` | Show help | | + +## Compressor Selection + +Priority order (auto-detected): + +1. **sips** (macOS built-in, WebP support since macOS 11) +2. **cwebp** (Google's official WebP tool) +3. **ImageMagick** (`convert` command) +4. **Sharp** (npm package, auto-installed by Bun) + +The skill automatically selects the best available compressor. + +## Output Format + +### Text Mode (default) + +``` +image.png → image.webp (245KB → 89KB, 64% reduction) +``` + +### JSON Mode + +```json +{ + "input": "image.png", + "output": "image.webp", + "inputSize": 250880, + "outputSize": 91136, + "ratio": 0.36, + "compressor": "sips" +} +``` + +### Directory JSON Mode + +```json +{ + "files": [...], + "summary": { + "totalFiles": 10, + "totalInputSize": 2508800, + "totalOutputSize": 911360, + "ratio": 0.36, + "compressor": "sips" + } +} +``` + +## Examples + +### Compress single image + +```bash +npx -y bun ${SKILL_DIR}/scripts/main.ts photo.png +# photo.png → photo.webp (1.2MB → 340KB, 72% reduction) +``` + +### Compress with custom quality + +```bash +npx -y bun ${SKILL_DIR}/scripts/main.ts photo.png -q 60 +# photo.png → photo.webp (1.2MB → 280KB, 77% reduction) +``` + +### Keep original format + +```bash +npx -y bun ${SKILL_DIR}/scripts/main.ts screenshot.png -f png --keep +# screenshot.png → screenshot-compressed.png (500KB → 380KB, 24% reduction) +``` + +### Process entire directory + +```bash +npx -y bun ${SKILL_DIR}/scripts/main.ts ./screenshots/ -r +# Processed 15 files: 12.5MB → 4.2MB (66% reduction) +``` + +### Get JSON for scripting + +```bash +npx -y bun ${SKILL_DIR}/scripts/main.ts image.png --json | jq '.ratio' +``` + +## Extension Support + +Custom configurations via EXTEND.md. + +**Check paths** (priority order): +1. `.baoyu-skills/baoyu-compress-image/EXTEND.md` (project) +2. `~/.baoyu-skills/baoyu-compress-image/EXTEND.md` (user) + +If found, load before workflow. Extension content overrides defaults. diff --git a/skills/baoyu-compress-image/scripts/main.ts b/skills/baoyu-compress-image/scripts/main.ts new file mode 100644 index 0000000..28a03e4 --- /dev/null +++ b/skills/baoyu-compress-image/scripts/main.ts @@ -0,0 +1,315 @@ +#!/usr/bin/env bun +import { existsSync, statSync, readdirSync, unlinkSync, renameSync } from "fs"; +import { basename, dirname, extname, join, resolve } from "path"; +import { spawn } from "child_process"; + +type Compressor = "sips" | "cwebp" | "imagemagick" | "sharp"; +type Format = "webp" | "png" | "jpeg"; + +interface Options { + input: string; + output?: string; + format: Format; + quality: number; + keep: boolean; + recursive: boolean; + json: boolean; +} + +interface Result { + input: string; + output: string; + inputSize: number; + outputSize: number; + ratio: number; + compressor: Compressor; +} + +const SUPPORTED_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".tiff"]; + +async function commandExists(cmd: string): Promise { + try { + const proc = spawn("which", [cmd], { stdio: "pipe" }); + return new Promise((res) => { + proc.on("close", (code) => res(code === 0)); + proc.on("error", () => res(false)); + }); + } catch { + return false; + } +} + +async function detectCompressor(format: Format): Promise { + if (format === "webp") { + if (await commandExists("cwebp")) return "cwebp"; + if (await commandExists("convert")) return "imagemagick"; + return "sharp"; + } + if (process.platform === "darwin") return "sips"; + if (await commandExists("convert")) return "imagemagick"; + return "sharp"; +} + +function runCmd(cmd: string, args: string[]): Promise<{ code: number; stderr: string }> { + return new Promise((res) => { + const proc = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] }); + let stderr = ""; + proc.stderr?.on("data", (d) => (stderr += d.toString())); + proc.on("close", (code) => res({ code: code ?? 1, stderr })); + proc.on("error", (e) => res({ code: 1, stderr: e.message })); + }); +} + +async function compressWithSips(input: string, output: string, format: Format, quality: number): Promise { + const fmt = format === "jpeg" ? "jpeg" : format; + const args = ["-s", "format", fmt, "-s", "formatOptions", String(quality), input, "--out", output]; + const { code, stderr } = await runCmd("sips", args); + if (code !== 0) throw new Error(`sips failed: ${stderr}`); +} + +async function compressWithCwebp(input: string, output: string, quality: number): Promise { + const args = ["-q", String(quality), input, "-o", output]; + const { code, stderr } = await runCmd("cwebp", args); + if (code !== 0) throw new Error(`cwebp failed: ${stderr}`); +} + +async function compressWithImagemagick(input: string, output: string, quality: number): Promise { + const args = [input, "-quality", String(quality), output]; + const { code, stderr } = await runCmd("convert", args); + if (code !== 0) throw new Error(`convert failed: ${stderr}`); +} + +async function compressWithSharp(input: string, output: string, format: Format, quality: number): Promise { + const sharp = (await import("sharp")).default; + let pipeline = sharp(input); + if (format === "webp") pipeline = pipeline.webp({ quality }); + else if (format === "png") pipeline = pipeline.png({ quality }); + else if (format === "jpeg") pipeline = pipeline.jpeg({ quality }); + await pipeline.toFile(output); +} + +async function compress( + compressor: Compressor, + input: string, + output: string, + format: Format, + quality: number +): Promise { + switch (compressor) { + case "sips": + await compressWithSips(input, output, format, quality); + break; + case "cwebp": + if (format !== "webp") { + await compressWithSharp(input, output, format, quality); + } else { + await compressWithCwebp(input, output, quality); + } + break; + case "imagemagick": + await compressWithImagemagick(input, output, quality); + break; + case "sharp": + await compressWithSharp(input, output, format, quality); + break; + } +} + +function getOutputPath(input: string, format: Format, keep: boolean, customOutput?: string): string { + if (customOutput) return resolve(customOutput); + const dir = dirname(input); + const base = basename(input, extname(input)); + const ext = format === "jpeg" ? ".jpg" : `.${format}`; + if (keep && extname(input).toLowerCase() === ext) { + return join(dir, `${base}-compressed${ext}`); + } + return join(dir, `${base}${ext}`); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +async function processFile( + compressor: Compressor, + input: string, + opts: Options +): Promise { + const absInput = resolve(input); + const inputSize = statSync(absInput).size; + const output = getOutputPath(absInput, opts.format, opts.keep, opts.output); + const tempOutput = output + ".tmp"; + + await compress(compressor, absInput, tempOutput, opts.format, opts.quality); + + const outputSize = statSync(tempOutput).size; + + if (!opts.keep && absInput !== output) { + unlinkSync(absInput); + } + renameSync(tempOutput, output); + + return { + input: absInput, + output, + inputSize, + outputSize, + ratio: outputSize / inputSize, + compressor, + }; +} + +function collectFiles(dir: string, recursive: boolean): string[] { + const files: string[] = []; + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory() && recursive) { + files.push(...collectFiles(full, recursive)); + } else if (entry.isFile() && SUPPORTED_EXTS.includes(extname(entry.name).toLowerCase())) { + files.push(full); + } + } + return files; +} + +function printHelp() { + console.log(`Usage: bun main.ts [options] + +Options: + -o, --output Output path + -f, --format Output format: webp, png, jpeg (default: webp) + -q, --quality Quality 0-100 (default: 80) + -k, --keep Keep original file + -r, --recursive Process directories recursively + --json JSON output + -h, --help Show help`); +} + +function parseArgs(args: string[]): Options | null { + const opts: Options = { + input: "", + format: "webp", + quality: 80, + keep: false, + recursive: false, + json: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "-h" || arg === "--help") { + printHelp(); + process.exit(0); + } else if (arg === "-o" || arg === "--output") { + opts.output = args[++i]; + } else if (arg === "-f" || arg === "--format") { + const fmt = args[++i]?.toLowerCase(); + if (fmt === "webp" || fmt === "png" || fmt === "jpeg" || fmt === "jpg") { + opts.format = fmt === "jpg" ? "jpeg" : (fmt as Format); + } else { + console.error(`Invalid format: ${fmt}`); + return null; + } + } else if (arg === "-q" || arg === "--quality") { + const q = parseInt(args[++i], 10); + if (isNaN(q) || q < 0 || q > 100) { + console.error(`Invalid quality: ${args[i]}`); + return null; + } + opts.quality = q; + } else if (arg === "-k" || arg === "--keep") { + opts.keep = true; + } else if (arg === "-r" || arg === "--recursive") { + opts.recursive = true; + } else if (arg === "--json") { + opts.json = true; + } else if (!arg.startsWith("-") && !opts.input) { + opts.input = arg; + } + } + + if (!opts.input) { + console.error("Error: Input file or directory required"); + printHelp(); + return null; + } + + return opts; +} + +async function main() { + const args = process.argv.slice(2); + const opts = parseArgs(args); + if (!opts) process.exit(1); + + const input = resolve(opts.input); + if (!existsSync(input)) { + console.error(`Error: ${input} not found`); + process.exit(1); + } + + const compressor = await detectCompressor(opts.format); + const isDir = statSync(input).isDirectory(); + + if (isDir) { + const files = collectFiles(input, opts.recursive); + if (files.length === 0) { + console.error("No supported images found"); + process.exit(1); + } + + const results: Result[] = []; + for (const file of files) { + try { + const r = await processFile(compressor, file, { ...opts, output: undefined }); + results.push(r); + if (!opts.json) { + const reduction = Math.round((1 - r.ratio) * 100); + console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`); + } + } catch (e) { + if (!opts.json) console.error(`Error processing ${file}: ${(e as Error).message}`); + } + } + + if (opts.json) { + const totalInput = results.reduce((s, r) => s + r.inputSize, 0); + const totalOutput = results.reduce((s, r) => s + r.outputSize, 0); + console.log( + JSON.stringify({ + files: results, + summary: { + totalFiles: results.length, + totalInputSize: totalInput, + totalOutputSize: totalOutput, + ratio: totalInput > 0 ? totalOutput / totalInput : 0, + compressor, + }, + }, null, 2) + ); + } else { + const totalInput = results.reduce((s, r) => s + r.inputSize, 0); + const totalOutput = results.reduce((s, r) => s + r.outputSize, 0); + const reduction = Math.round((1 - totalOutput / totalInput) * 100); + console.log(`\nProcessed ${results.length} files: ${formatSize(totalInput)} → ${formatSize(totalOutput)} (${reduction}% reduction)`); + } + } else { + try { + const r = await processFile(compressor, input, opts); + if (opts.json) { + console.log(JSON.stringify(r, null, 2)); + } else { + const reduction = Math.round((1 - r.ratio) * 100); + console.log(`${r.input} → ${r.output} (${formatSize(r.inputSize)} → ${formatSize(r.outputSize)}, ${reduction}% reduction)`); + } + } catch (e) { + console.error(`Error: ${(e as Error).message}`); + process.exit(1); + } + } +} + +main();