@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 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