@puntoycoma/bulki 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +329 -0
- package/dist/bin/bulki.js +331 -0
- package/dist/bin/bulki.js.map +1 -0
- package/dist/chunk-OMZC5IWI.js +501 -0
- package/dist/chunk-OMZC5IWI.js.map +1 -0
- package/dist/interactive-LGL325GZ.js +294 -0
- package/dist/interactive-LGL325GZ.js.map +1 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# @puntoycoma/bulki
|
|
2
|
+
|
|
3
|
+
Ultra-fast, free, open-source CLI image optimizer with interactive mode, AI editor integration, and automation tools.
|
|
4
|
+
|
|
5
|
+
Built on [Sharp](https://sharp.pixelplumbing.com/) (libvips). Compress, resize, and convert JPEG, PNG, WebP, GIF, and AVIF images in bulk.
|
|
6
|
+
|
|
7
|
+
## Why bulki?
|
|
8
|
+
|
|
9
|
+
- **Fast** — Powered by Sharp/libvips, 4-5x faster than ImageMagick
|
|
10
|
+
- **Interactive** — Run `bulki` with no arguments for a guided experience
|
|
11
|
+
- **AI-ready** — Built-in MCP server for Claude Code, Cursor, Windsurf, and Cline
|
|
12
|
+
- **Automated** — Git hooks, watch mode, and CI/CD integration
|
|
13
|
+
- **Lightweight** — Minimal dependencies, 15 KB package size
|
|
14
|
+
- **Free** — MIT licensed, no API keys, no accounts, runs 100% locally
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install -g @puntoycoma/bulki
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
### Interactive mode
|
|
25
|
+
|
|
26
|
+
Just run `bulki` with no arguments:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bulki
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
You get a guided menu with presets and custom options — no commands to memorize.
|
|
33
|
+
|
|
34
|
+
### CLI mode
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Compress all images in a directory
|
|
38
|
+
bulki compress ./photos
|
|
39
|
+
|
|
40
|
+
# Convert to WebP with quality 80
|
|
41
|
+
bulki compress ./photos -f webp -q 80
|
|
42
|
+
|
|
43
|
+
# Resize to max 1920px width
|
|
44
|
+
bulki compress ./photos -r 1920
|
|
45
|
+
|
|
46
|
+
# Compress a single file
|
|
47
|
+
bulki compress ./photo.png -q 85 -f avif
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
### Compress images
|
|
53
|
+
|
|
54
|
+
Optimize images with full control over format, quality, and dimensions.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bulki compress <path> [options]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
| Option | Alias | Description | Default |
|
|
61
|
+
|--------|-------|-------------|---------|
|
|
62
|
+
| `--quality <n>` | `-q` | Output quality (1-100) | `70` |
|
|
63
|
+
| `--format <fmt>` | `-f` | Convert to: `jpeg`, `png`, `webp`, `gif`, `avif` | original |
|
|
64
|
+
| `--resize <width>` | `-r` | Max width in pixels | original |
|
|
65
|
+
| `--destination <path>` | `-d` | Output base directory | `.` |
|
|
66
|
+
| `--output-folder <name>` | `-o` | Output folder name | `bulki-output` |
|
|
67
|
+
| `--concurrency <n>` | | Parallel operations (1-32) | auto |
|
|
68
|
+
| `--exclude <patterns...>` | | Directory names to skip | none |
|
|
69
|
+
| `--dry-run` | | Preview without writing files | `false` |
|
|
70
|
+
| `--verbose` | | Show per-file details | `false` |
|
|
71
|
+
| `--json` | | Machine-readable JSON output | `false` |
|
|
72
|
+
| `--max-size <size>` | | Fail if output exceeds limit | none |
|
|
73
|
+
|
|
74
|
+
#### Examples
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Optimize for web: WebP, quality 80, max 1920px
|
|
78
|
+
bulki compress ./assets -f webp -q 80 -r 1920
|
|
79
|
+
|
|
80
|
+
# Preview what would be processed
|
|
81
|
+
bulki compress ./uploads --dry-run
|
|
82
|
+
|
|
83
|
+
# CI/CD: fail if any image exceeds 200 KB
|
|
84
|
+
bulki compress ./public --max-size 200kb
|
|
85
|
+
|
|
86
|
+
# JSON output for scripts and automation
|
|
87
|
+
bulki compress ./images --json
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### JSON output
|
|
91
|
+
|
|
92
|
+
Use `--json` for machine-readable output:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"files": [
|
|
97
|
+
{
|
|
98
|
+
"input": "/path/to/photo.jpg",
|
|
99
|
+
"output": "/path/to/bulki-output/photo.webp",
|
|
100
|
+
"originalSize": 2400000,
|
|
101
|
+
"newSize": 680000,
|
|
102
|
+
"reduction": 72,
|
|
103
|
+
"format": "webp",
|
|
104
|
+
"duration": 45
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
"summary": {
|
|
108
|
+
"totalFiles": 1,
|
|
109
|
+
"totalOriginalSize": 2400000,
|
|
110
|
+
"totalNewSize": 680000,
|
|
111
|
+
"totalReduction": 72,
|
|
112
|
+
"duration": 120,
|
|
113
|
+
"errors": 0
|
|
114
|
+
},
|
|
115
|
+
"errors": []
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
#### Size budget
|
|
120
|
+
|
|
121
|
+
Prevent oversized images in your project:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Fail if any optimized image exceeds 200 KB
|
|
125
|
+
bulki compress ./assets --max-size 200kb
|
|
126
|
+
|
|
127
|
+
# Combined with JSON for CI pipelines
|
|
128
|
+
bulki compress ./public --max-size 500kb --json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Exit code is `1` if any file exceeds the budget — perfect for CI checks.
|
|
132
|
+
|
|
133
|
+
### Pipe (stdin/stdout)
|
|
134
|
+
|
|
135
|
+
Process images through Unix pipes:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
bulki pipe -f <format> [-q quality] [-r width]
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### Examples
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Download and optimize in one command
|
|
145
|
+
curl -s https://example.com/photo.jpg | bulki pipe -f webp -q 80 > photo.webp
|
|
146
|
+
|
|
147
|
+
# Convert format on the fly
|
|
148
|
+
cat input.png | bulki pipe -f avif -q 70 > output.avif
|
|
149
|
+
|
|
150
|
+
# Chain with other tools
|
|
151
|
+
cat photo.jpg | bulki pipe -f webp -r 800 | aws s3 cp - s3://bucket/photo.webp
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
#### Use cases
|
|
155
|
+
|
|
156
|
+
- **CI/CD pipelines** — optimize images during build without temp files
|
|
157
|
+
- **Webhooks** — receive image, optimize, forward in one stream
|
|
158
|
+
- **Scripts** — compose with curl, aws, gsutil, or any Unix tool
|
|
159
|
+
|
|
160
|
+
### Watch mode
|
|
161
|
+
|
|
162
|
+
Monitor a directory and auto-optimize new images:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
bulki watch <directory> [options]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
#### Examples
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# Watch a folder and convert new images to WebP
|
|
172
|
+
bulki watch ./uploads -f webp -q 80
|
|
173
|
+
|
|
174
|
+
# Watch with resize
|
|
175
|
+
bulki watch ./photos -r 1920 -q 85 -d ./optimized
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
#### Use cases
|
|
179
|
+
|
|
180
|
+
- **Photographers** — drop photos from camera, get optimized versions automatically
|
|
181
|
+
- **Designers** — export from Figma/Sketch, images optimize on save
|
|
182
|
+
- **Local servers** — monitor an upload directory
|
|
183
|
+
|
|
184
|
+
### Git hook
|
|
185
|
+
|
|
186
|
+
Auto-optimize images on every commit:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
# Install the pre-commit hook
|
|
190
|
+
bulki hook install
|
|
191
|
+
|
|
192
|
+
# Remove it
|
|
193
|
+
bulki hook uninstall
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Once installed, every time you `git commit` an image file (JPEG, PNG, WebP, GIF, AVIF), bulki optimizes it automatically before the commit completes.
|
|
197
|
+
|
|
198
|
+
#### Use cases
|
|
199
|
+
|
|
200
|
+
- **Teams** — no one commits unoptimized images, ever
|
|
201
|
+
- **CI savings** — smaller images = faster builds and deploys
|
|
202
|
+
- **Repo health** — keep your repository lean without manual work
|
|
203
|
+
|
|
204
|
+
### MCP Server (AI editor integration)
|
|
205
|
+
|
|
206
|
+
bulki exposes an MCP (Model Context Protocol) server so AI coding assistants can optimize images directly.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
bulki mcp
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
This starts a local MCP server (no API key, no internet required) that provides two tools:
|
|
213
|
+
|
|
214
|
+
| Tool | Description |
|
|
215
|
+
|------|-------------|
|
|
216
|
+
| `compress` | Optimize images in a file or directory |
|
|
217
|
+
| `detect` | Check if a file is an image and identify its format |
|
|
218
|
+
|
|
219
|
+
#### Setup with Claude Code
|
|
220
|
+
|
|
221
|
+
Add to your Claude Code MCP config (`~/.claude/claude_desktop_config.json`):
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"mcpServers": {
|
|
226
|
+
"bulki": {
|
|
227
|
+
"command": "npx",
|
|
228
|
+
"args": ["@puntoycoma/bulki", "mcp"]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Then in Claude Code, you can say:
|
|
235
|
+
|
|
236
|
+
> "Optimize all images in the project for web"
|
|
237
|
+
|
|
238
|
+
And Claude will call bulki directly.
|
|
239
|
+
|
|
240
|
+
#### Setup with Cursor
|
|
241
|
+
|
|
242
|
+
Add to `.cursor/mcp.json` in your project:
|
|
243
|
+
|
|
244
|
+
```json
|
|
245
|
+
{
|
|
246
|
+
"mcpServers": {
|
|
247
|
+
"bulki": {
|
|
248
|
+
"command": "npx",
|
|
249
|
+
"args": ["@puntoycoma/bulki", "mcp"]
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
#### Use cases
|
|
256
|
+
|
|
257
|
+
- **Claude Code** — "compress all PNG files to WebP"
|
|
258
|
+
- **Cursor** — "optimize the images in /public"
|
|
259
|
+
- **Windsurf / Cline** — any MCP-compatible AI editor
|
|
260
|
+
- **Custom agents** — build your own tools with bulki as a backend
|
|
261
|
+
|
|
262
|
+
## Supported formats
|
|
263
|
+
|
|
264
|
+
| Format | Compress | Convert to | Convert from |
|
|
265
|
+
|--------|----------|------------|--------------|
|
|
266
|
+
| JPEG | yes | yes | yes |
|
|
267
|
+
| PNG | yes | yes | yes |
|
|
268
|
+
| WebP | yes | yes | yes |
|
|
269
|
+
| GIF | yes | yes | yes |
|
|
270
|
+
| AVIF | yes | yes | yes |
|
|
271
|
+
|
|
272
|
+
## Output structure
|
|
273
|
+
|
|
274
|
+
Optimized images are saved preserving the original folder structure:
|
|
275
|
+
|
|
276
|
+
```
|
|
277
|
+
photos/ bulki-output/
|
|
278
|
+
vacation/ -> vacation/
|
|
279
|
+
beach.jpg beach.jpg
|
|
280
|
+
sunset.png sunset.png
|
|
281
|
+
profile.png profile.png
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
## Integration examples
|
|
285
|
+
|
|
286
|
+
### GitHub Actions
|
|
287
|
+
|
|
288
|
+
```yaml
|
|
289
|
+
- name: Optimize images
|
|
290
|
+
run: npx @puntoycoma/bulki compress ./public -f webp -q 80 --max-size 500kb
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### npm scripts
|
|
294
|
+
|
|
295
|
+
```json
|
|
296
|
+
{
|
|
297
|
+
"scripts": {
|
|
298
|
+
"optimize": "bulki compress ./src/assets -f webp -q 80",
|
|
299
|
+
"optimize:check": "bulki compress ./public --max-size 200kb --dry-run"
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Pre-commit (manual setup)
|
|
305
|
+
|
|
306
|
+
```bash
|
|
307
|
+
# In your project
|
|
308
|
+
bulki hook install
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Backward compatibility
|
|
312
|
+
|
|
313
|
+
The `gio` command from the original `gab-images-optimizer` package still works:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
gio compress ./photos -q 80
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Requirements
|
|
320
|
+
|
|
321
|
+
- Node.js >= 20
|
|
322
|
+
|
|
323
|
+
## License
|
|
324
|
+
|
|
325
|
+
MIT
|
|
326
|
+
|
|
327
|
+
## Contributing
|
|
328
|
+
|
|
329
|
+
Issues and PRs welcome at [github.com/PuntoyComaTech/bulki](https://github.com/PuntoyComaTech/bulki).
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
VALID_FORMATS,
|
|
4
|
+
buildJsonOutput,
|
|
5
|
+
collectImageFiles,
|
|
6
|
+
detectFormat,
|
|
7
|
+
formatFileResult,
|
|
8
|
+
formatSummary,
|
|
9
|
+
getFormatOptions,
|
|
10
|
+
hookCommand,
|
|
11
|
+
optimizeImage,
|
|
12
|
+
parseSize,
|
|
13
|
+
processPool,
|
|
14
|
+
validateOptions,
|
|
15
|
+
watchCommand
|
|
16
|
+
} from "../chunk-OMZC5IWI.js";
|
|
17
|
+
|
|
18
|
+
// src/bin/bulki.ts
|
|
19
|
+
import { createRequire as createRequire2 } from "module";
|
|
20
|
+
import { Command as Command4 } from "commander";
|
|
21
|
+
|
|
22
|
+
// src/commands/compress.ts
|
|
23
|
+
import path from "path";
|
|
24
|
+
import { Command } from "commander";
|
|
25
|
+
|
|
26
|
+
// src/output/progress.ts
|
|
27
|
+
function createProgressBar(total) {
|
|
28
|
+
let current = 0;
|
|
29
|
+
return {
|
|
30
|
+
tick() {
|
|
31
|
+
current++;
|
|
32
|
+
const percent = Math.round(current / total * 100);
|
|
33
|
+
const barWidth = Math.max(0, Math.min(process.stdout.columns || 40, 40) - 20);
|
|
34
|
+
const filled = Math.round(current / total * barWidth);
|
|
35
|
+
const empty = barWidth - filled;
|
|
36
|
+
const bar = `[${"=".repeat(filled)}${" ".repeat(empty)}] ${percent}% ${current}/${total}`;
|
|
37
|
+
process.stdout.write(`\r${bar}`);
|
|
38
|
+
if (current === total) process.stdout.write("\n");
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/commands/compress.ts
|
|
44
|
+
var compressCommand = new Command("compress").description("Compress images in a file or directory").argument("<path>", "File or directory to process").option("-q, --quality <n>", "Output quality (1-100)", (v) => parseInt(v, 10), 70).option("-f, --format <fmt>", "Output format (jpeg, png, webp, gif, avif)").option("-r, --resize <width>", "Resize to width (px)", (v) => parseInt(v, 10)).option("-d, --destination <path>", "Destination directory", ".").option("-o, --output-folder <name>", "Output subfolder name", "bulki-output").option("--concurrency <n>", "Number of parallel operations", (v) => parseInt(v, 10)).option("--exclude <patterns...>", "Directory names to exclude").option("--dry-run", "Show what would be processed without writing files").option("--verbose", "Show per-file output").option("--json", "Output results as JSON").option("--max-size <size>", "Maximum file size budget (e.g., 200kb, 1mb)").action(async (inputPath, opts) => {
|
|
45
|
+
let options;
|
|
46
|
+
try {
|
|
47
|
+
options = validateOptions(opts);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(err.message);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const resolvedPath = path.resolve(inputPath);
|
|
53
|
+
let filePaths;
|
|
54
|
+
let isDirectory;
|
|
55
|
+
try {
|
|
56
|
+
const collected = await collectImageFiles(resolvedPath, options.exclude);
|
|
57
|
+
filePaths = collected.files;
|
|
58
|
+
isDirectory = collected.isDirectory;
|
|
59
|
+
} catch {
|
|
60
|
+
console.error(`Path not found: ${resolvedPath}`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (filePaths.length === 0) {
|
|
64
|
+
console.log("No images found to process.");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (options.dryRun) {
|
|
68
|
+
console.log(`Would process ${filePaths.length} image(s)`);
|
|
69
|
+
for (const f of filePaths) console.log(` ${path.basename(f.path)}`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const sourcePath = isDirectory ? resolvedPath : path.dirname(resolvedPath);
|
|
73
|
+
const startTime = performance.now();
|
|
74
|
+
const progress = !options.verbose && !options.json ? createProgressBar(filePaths.length) : null;
|
|
75
|
+
const errors = [];
|
|
76
|
+
const jsonErrors = [];
|
|
77
|
+
const processedResults = await processPool(
|
|
78
|
+
filePaths,
|
|
79
|
+
async (file) => {
|
|
80
|
+
try {
|
|
81
|
+
const result = await optimizeImage(file.path, {
|
|
82
|
+
...options,
|
|
83
|
+
sourcePath,
|
|
84
|
+
detectedFormat: file.format
|
|
85
|
+
});
|
|
86
|
+
if (options.verbose && !options.json) {
|
|
87
|
+
console.log(formatFileResult(result));
|
|
88
|
+
}
|
|
89
|
+
progress?.tick();
|
|
90
|
+
return result;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
errors.push(err);
|
|
93
|
+
jsonErrors.push({ file: file.path, message: err.message });
|
|
94
|
+
progress?.tick();
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
options.concurrency
|
|
99
|
+
);
|
|
100
|
+
const validResults = processedResults.filter((r) => r !== null);
|
|
101
|
+
const totalDuration = performance.now() - startTime;
|
|
102
|
+
if (options.json) {
|
|
103
|
+
const output = buildJsonOutput(validResults, jsonErrors, totalDuration);
|
|
104
|
+
if (options.maxSize) {
|
|
105
|
+
for (const file of output.files) {
|
|
106
|
+
if (file.newSize > options.maxSize) {
|
|
107
|
+
file.exceeded = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
console.log(JSON.stringify(output));
|
|
112
|
+
} else {
|
|
113
|
+
console.log(formatSummary(validResults, errors, totalDuration));
|
|
114
|
+
}
|
|
115
|
+
if (validResults.length === 0 && errors.length > 0) {
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
const maxSize = options.maxSize;
|
|
119
|
+
if (maxSize) {
|
|
120
|
+
const exceeded = validResults.filter((r) => r.newSize > maxSize);
|
|
121
|
+
if (exceeded.length > 0) {
|
|
122
|
+
if (!options.json) {
|
|
123
|
+
for (const r of exceeded) {
|
|
124
|
+
console.warn(`File exceeds size budget: ${r.outputPath}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// src/commands/mcp.ts
|
|
133
|
+
import { createRequire } from "module";
|
|
134
|
+
import path2 from "path";
|
|
135
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
136
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
137
|
+
import { Command as Command2 } from "commander";
|
|
138
|
+
import { z } from "zod";
|
|
139
|
+
var require2 = createRequire(import.meta.url);
|
|
140
|
+
var { version } = require2("../../package.json");
|
|
141
|
+
var mcpCommand = new Command2("mcp").description("Start MCP server for AI editor integration (Claude Code, Cursor, etc.)").action(async () => {
|
|
142
|
+
const server = new McpServer({
|
|
143
|
+
name: "bulki",
|
|
144
|
+
version
|
|
145
|
+
});
|
|
146
|
+
server.tool(
|
|
147
|
+
"compress",
|
|
148
|
+
"Compress and optimize images. Supports JPEG, PNG, WebP, GIF, AVIF.",
|
|
149
|
+
{
|
|
150
|
+
path: z.string().describe("File or directory path to compress"),
|
|
151
|
+
quality: z.number().min(1).max(100).default(70).describe("Output quality (1-100)"),
|
|
152
|
+
format: z.enum(["jpeg", "png", "webp", "gif", "avif"]).optional().describe("Output format (omit to keep original)"),
|
|
153
|
+
resize: z.number().positive().optional().describe("Max width in pixels"),
|
|
154
|
+
destination: z.string().default(".").describe("Output directory"),
|
|
155
|
+
outputFolder: z.string().default("bulki-output").describe("Output folder name"),
|
|
156
|
+
maxSize: z.string().optional().describe("Max file size budget (e.g., 200kb, 1mb)")
|
|
157
|
+
},
|
|
158
|
+
async (params) => {
|
|
159
|
+
if (params.outputFolder.includes("/") || params.outputFolder.includes("\\") || params.outputFolder.includes("..")) {
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: "text",
|
|
164
|
+
text: JSON.stringify({
|
|
165
|
+
error: "Invalid outputFolder: must be a simple directory name"
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
]
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const resolvedPath = path2.resolve(params.path);
|
|
172
|
+
let filePaths;
|
|
173
|
+
let isDirectory;
|
|
174
|
+
try {
|
|
175
|
+
const collected = await collectImageFiles(resolvedPath, []);
|
|
176
|
+
filePaths = collected.files;
|
|
177
|
+
isDirectory = collected.isDirectory;
|
|
178
|
+
} catch {
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: "text",
|
|
183
|
+
text: JSON.stringify({ error: `Path not found: ${resolvedPath}` })
|
|
184
|
+
}
|
|
185
|
+
]
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (filePaths.length === 0) {
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
type: "text",
|
|
193
|
+
text: JSON.stringify({ files: [], summary: { totalFiles: 0 } })
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const sourcePath = isDirectory ? resolvedPath : path2.dirname(resolvedPath);
|
|
199
|
+
const startTime = performance.now();
|
|
200
|
+
const results = [];
|
|
201
|
+
const errors = [];
|
|
202
|
+
let maxSizeBytes;
|
|
203
|
+
if (params.maxSize) {
|
|
204
|
+
try {
|
|
205
|
+
maxSizeBytes = parseSize(params.maxSize);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return {
|
|
208
|
+
content: [
|
|
209
|
+
{
|
|
210
|
+
type: "text",
|
|
211
|
+
text: JSON.stringify({ error: err.message })
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const file of filePaths) {
|
|
218
|
+
try {
|
|
219
|
+
const result = await optimizeImage(file.path, {
|
|
220
|
+
quality: params.quality,
|
|
221
|
+
format: params.format,
|
|
222
|
+
resize: params.resize,
|
|
223
|
+
destination: params.destination,
|
|
224
|
+
outputFolder: params.outputFolder,
|
|
225
|
+
concurrency: 1,
|
|
226
|
+
exclude: [],
|
|
227
|
+
dryRun: false,
|
|
228
|
+
verbose: false,
|
|
229
|
+
json: false,
|
|
230
|
+
sourcePath,
|
|
231
|
+
detectedFormat: file.format
|
|
232
|
+
});
|
|
233
|
+
results.push(result);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
errors.push({ file: file.path, message: err.message });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const totalDuration = performance.now() - startTime;
|
|
239
|
+
const output = buildJsonOutput(results, errors, totalDuration);
|
|
240
|
+
if (maxSizeBytes !== void 0) {
|
|
241
|
+
for (const file of output.files) {
|
|
242
|
+
if (file.newSize > maxSizeBytes) {
|
|
243
|
+
file.exceeded = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
server.tool(
|
|
251
|
+
"detect",
|
|
252
|
+
"Detect if a file is an image and identify its format",
|
|
253
|
+
{
|
|
254
|
+
path: z.string().describe("Path to the file")
|
|
255
|
+
},
|
|
256
|
+
async (params) => {
|
|
257
|
+
const resolvedPath = path2.resolve(params.path);
|
|
258
|
+
const format = await detectFormat(resolvedPath);
|
|
259
|
+
const result = format ? { format, isImage: true } : { format: null, isImage: false };
|
|
260
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
261
|
+
}
|
|
262
|
+
);
|
|
263
|
+
const transport = new StdioServerTransport();
|
|
264
|
+
await server.connect(transport);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// src/commands/pipe.ts
|
|
268
|
+
import { Command as Command3 } from "commander";
|
|
269
|
+
import sharp from "sharp";
|
|
270
|
+
var pipeCommand = new Command3("pipe").description("Read image from stdin, write optimized image to stdout").requiredOption("-f, --format <fmt>", "Output format (jpeg, png, webp, gif, avif)").option("-q, --quality <n>", "Quality (1-100)", (v) => parseInt(v, 10), 70).option("-r, --resize <width>", "Resize to width (px)", (v) => parseInt(v, 10)).action(async (opts) => {
|
|
271
|
+
const MAX_INPUT_SIZE = 100 * 1024 * 1024;
|
|
272
|
+
const chunks = [];
|
|
273
|
+
let totalSize = 0;
|
|
274
|
+
for await (const chunk of process.stdin) {
|
|
275
|
+
totalSize += chunk.length;
|
|
276
|
+
if (totalSize > MAX_INPUT_SIZE) {
|
|
277
|
+
process.stderr.write("Error: input exceeds 100 MB limit\n");
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
chunks.push(chunk);
|
|
281
|
+
}
|
|
282
|
+
const input = Buffer.concat(chunks);
|
|
283
|
+
if (input.length === 0) {
|
|
284
|
+
process.stderr.write("Error: no input received on stdin\n");
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
const format = opts.format;
|
|
288
|
+
const quality = opts.quality;
|
|
289
|
+
const resize = opts.resize;
|
|
290
|
+
if (!VALID_FORMATS.includes(format)) {
|
|
291
|
+
process.stderr.write(`Error: invalid format "${format}"
|
|
292
|
+
`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
const outputFormat = format === "jpg" ? "jpeg" : format;
|
|
296
|
+
const formatOptions = getFormatOptions(quality);
|
|
297
|
+
try {
|
|
298
|
+
let pipeline = sharp(input);
|
|
299
|
+
if (resize) {
|
|
300
|
+
pipeline = pipeline.resize(resize);
|
|
301
|
+
}
|
|
302
|
+
pipeline = pipeline.toFormat(
|
|
303
|
+
outputFormat,
|
|
304
|
+
formatOptions[outputFormat] ?? {}
|
|
305
|
+
);
|
|
306
|
+
const outputBuffer = await pipeline.toBuffer();
|
|
307
|
+
process.stdout.write(outputBuffer);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
process.stderr.write(`Error: ${err.message}
|
|
310
|
+
`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// src/bin/bulki.ts
|
|
316
|
+
var require3 = createRequire2(import.meta.url);
|
|
317
|
+
var { version: version2 } = require3("../../package.json");
|
|
318
|
+
if (process.argv.length <= 2) {
|
|
319
|
+
const { runInteractive } = await import("../interactive-LGL325GZ.js");
|
|
320
|
+
await runInteractive();
|
|
321
|
+
} else {
|
|
322
|
+
const program = new Command4();
|
|
323
|
+
program.name("bulki").description("Ultra-fast CLI image optimizer").version(version2);
|
|
324
|
+
program.addCommand(compressCommand);
|
|
325
|
+
program.addCommand(hookCommand);
|
|
326
|
+
program.addCommand(mcpCommand);
|
|
327
|
+
program.addCommand(pipeCommand);
|
|
328
|
+
program.addCommand(watchCommand);
|
|
329
|
+
program.parse();
|
|
330
|
+
}
|
|
331
|
+
//# sourceMappingURL=bulki.js.map
|