chore: release v1.1.0

This commit is contained in:
Jim Liu 宝玉
2026-01-18 20:11:49 -06:00
parent 464edf0656
commit 97bc68efd8
8 changed files with 766 additions and 96 deletions
+188
View File
@@ -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/<script-name>.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>` | | Input file or directory | Required |
| `--output <path>` | `-o` | Output path | Same path, new extension |
| `--format <fmt>` | `-f` | webp, png, jpeg | webp |
| `--quality <n>` | `-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.
+315
View File
@@ -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<boolean> {
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<Compressor> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Result> {
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 <input> [options]
Options:
-o, --output <path> Output path
-f, --format <fmt> Output format: webp, png, jpeg (default: webp)
-q, --quality <n> 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();