@puntoycoma/bulki 3.0.2 → 3.0.3

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 CHANGED
@@ -35,44 +35,69 @@ npm install -g @puntoycoma/bulki
35
35
  bulki
36
36
  ```
37
37
 
38
- Guided menu with presets:
39
- - **Web optimized** — WebP, quality 80, max 1920px
40
- - **Social media** — PNG, quality 85, max 1080px
41
- - **Custom** — you choose everything
38
+ Guided step-by-step mode. Drag a folder (or several images) into the terminal window when it asks
39
+ for a path — quotes, escaped spaces and trailing spaces are handled for you. You can also start it
40
+ with a path: `bulki ~/Desktop/My\ Photos`.
41
+
42
+ Presets:
43
+ - **Web optimized** — WebP, quality 80, max 1920px wide
44
+ - **Social media** — JPEG, quality 85, max 1080px wide
45
+ - **Just make them smaller** — same format and size, quality 80
46
+ - **Custom** — you choose format, quality and size
47
+
48
+ Navigation: **Esc** goes back one step (nothing you chose is lost), **Ctrl+C** quits. A wrong path or
49
+ number is rejected in place so you can fix it. Before starting you get a summary where you can change
50
+ the images, settings or output folder; afterwards you can open the output folder or run again with
51
+ different settings.
42
52
 
43
53
  ### CLI mode
44
54
 
45
55
  ```bash
46
- bulki compress ./photos # optimize with defaults
47
- bulki compress ./photos -f webp -q 80 # convert to WebP
48
- bulki compress ./photos -r 1920 -q 85 # resize to 1920px
49
- bulki compress hero.png -f avif -q 70 # single file to AVIF
56
+ bulki compress ./photos # → ./bulki-output
57
+ bulki compress ./photos -o ./web -f webp -q 80 # convert to WebP into ./web
58
+ bulki compress "My Photos" -o "Web Ready" -r 1920
59
+ bulki compress hero.png logo.png -f avif # several files at once (globs work too)
50
60
  ```
51
61
 
52
62
  ---
53
63
 
54
64
  ## Compress
55
65
 
56
- Optimize one file or an entire directory. Preserves folder structure.
66
+ Optimize files and/or folders. **Originals are never modified** — optimized copies are written to the
67
+ output folder, mirroring the folder structure.
57
68
 
58
69
  ```bash
59
- bulki compress <path> [options]
70
+ bulki compress <paths...> [options]
60
71
  ```
61
72
 
62
73
  | Option | Alias | Description | Default |
63
74
  |--------|-------|-------------|---------|
75
+ | `--output <dir>` | `-o` | Folder where optimized images are written | `./bulki-output` |
64
76
  | `--quality <n>` | `-q` | Output quality (1-100) | `70` |
65
- | `--format <fmt>` | `-f` | `jpeg`, `png`, `webp`, `gif`, `avif` | original |
66
- | `--resize <width>` | `-r` | Max width in pixels | original |
67
- | `--destination <path>` | `-d` | Output base directory | `.` |
68
- | `--output-folder <name>` | `-o` | Output folder name | `bulki-output` |
69
- | `--concurrency <n>` | | Parallel operations (1-32) | auto |
70
- | `--exclude <patterns...>` | | Directory names to skip | none |
71
- | `--dry-run` | | Preview without writing | `false` |
72
- | `--verbose` | | Per-file details | `false` |
77
+ | `--format <fmt>` | `-f` | `jpeg`, `png`, `webp`, `gif`, `avif` | keep original |
78
+ | `--resize <width>` | `-r` | Max width in pixels (never enlarges) | original |
79
+ | `--concurrency <n>` | | Images processed in parallel (1-32) | CPU count (max 8) |
80
+ | `--exclude <names...>` | | Folder names to skip | none |
81
+ | `--dry-run` | | Show what would be written, without writing | `false` |
82
+ | `--verbose` | | One line per file | `false` |
73
83
  | `--json` | | Machine-readable JSON output | `false` |
74
84
  | `--max-size <size>` | | Fail if output exceeds limit (e.g., `200kb`, `1mb`) | none |
75
85
 
86
+ The path you want to optimize is the argument (`bulki compress <path>`); `-o` is only where the
87
+ results go. Paths with spaces must be quoted (`"My Photos"`) or escaped (`My\ Photos`) — or just
88
+ drag the folder into the terminal.
89
+
90
+ > **Upgrading from 3.0?** `-d/--destination <dir>` and `--output-folder <name>` still work
91
+ > (`-d x` → `x/bulki-output`, `-d x -o name` → `x/name`) but are deprecated in favor of `-o <dir>`.
92
+
93
+ Behaviour worth knowing:
94
+ - Previous `bulki-output` folders inside the source are skipped, so running twice doesn't nest outputs.
95
+ - If re-encoding would make an image bigger, the original bytes are copied instead.
96
+ - EXIF orientation is applied (phone photos won't come out sideways); transparent PNG → JPEG gets a white background.
97
+ - `photo.jpg` and `photo.png` converted to WebP become `photo.webp` and `photo-png.webp` (nothing is silently overwritten).
98
+ - HEIC/HEIF photos (iPhone) are converted on macOS.
99
+ - A broken image doesn't stop the batch: it's reported at the end and the exit code is `1`.
100
+
76
101
  Output preserves your folder structure:
77
102
 
78
103
  ```
@@ -136,7 +161,9 @@ Monitor a directory. Every time a new image appears, bulki optimizes it automati
136
161
  bulki watch <directory> [options]
137
162
  ```
138
163
 
139
- Same options as compress: `--quality`, `--format`, `--resize`, `--destination`.
164
+ Same options as compress: `--output`, `--quality`, `--format`, `--resize`. Files are processed once
165
+ they stop changing (large copies aren't read half-written), and edits to an existing image are
166
+ re-optimized.
140
167
 
141
168
  ### Examples
142
169
 
@@ -145,7 +172,7 @@ Same options as compress: `--quality`, `--format`, `--resize`, `--destination`.
145
172
  bulki watch ./uploads -f webp -q 80
146
173
 
147
174
  # Watch with resize, output to a different folder
148
- bulki watch ./photos -r 1920 -q 85 -d ./optimized
175
+ bulki watch ./photos -r 1920 -q 85 -o ./optimized
149
176
  ```
150
177
 
151
178
  ### Use cases
package/dist/bin/bulki.js CHANGED
@@ -1,26 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- VALID_FORMATS,
3
+ DEFAULTS,
4
+ PlanError,
5
+ UNSUPPORTED,
4
6
  buildJsonOutput,
5
- collectImageFiles,
7
+ defaultConcurrency,
6
8
  detectFormat,
9
+ displayPath,
10
+ encodeImage,
7
11
  formatFileResult,
8
12
  formatSummary,
9
- getFormatOptions,
10
13
  hookCommand,
11
- optimizeImage,
14
+ parseInteger,
15
+ parsePathInput,
12
16
  parseSize,
13
- processPool,
17
+ planJobs,
18
+ resolveOutputDir,
19
+ resolveUserPath,
20
+ runBatch,
21
+ validateEncodeSettings,
14
22
  validateOptions,
15
23
  watchCommand
16
- } from "../chunk-7TFBGWBB.js";
24
+ } from "../chunk-YUAKNSBH.js";
17
25
 
18
26
  // src/bin/bulki.ts
19
27
  import { createRequire as createRequire2 } from "module";
20
28
  import { Command as Command4 } from "commander";
21
29
 
22
30
  // src/commands/compress.ts
23
- import path from "path";
24
31
  import { Command } from "commander";
25
32
 
26
33
  // src/output/progress.ts
@@ -41,297 +48,208 @@ function createProgressBar(total) {
41
48
  }
42
49
 
43
50
  // 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) => {
51
+ var compressCommand = new Command("compress").description(
52
+ "Compress images. Originals are never modified; optimized copies go to the output folder."
53
+ ).argument("<paths...>", "Images and/or folders to optimize (quote paths with spaces)").option("-o, --output <dir>", "Folder where optimized images are written", DEFAULTS.output).option("-q, --quality <n>", "Output quality (1-100)", parseInteger, DEFAULTS.quality).option("-f, --format <fmt>", "Convert to jpeg, png, webp, gif or avif (default: keep format)").option(
54
+ "-r, --resize <width>",
55
+ "Max width in px (smaller images are never enlarged)",
56
+ parseInteger
57
+ ).option("--concurrency <n>", "Images processed in parallel (1-32)", parseInteger).option("--exclude <names...>", "Folder names to skip").option("--dry-run", "Show what would be processed without writing files").option("--verbose", "Show one line per file").option("--json", "Output results as JSON").option("--max-size <size>", "Fail if any output is bigger than this (e.g. 200kb, 1mb)").option("-d, --destination <dir>", "Deprecated: base folder; output goes to <dir>/bulki-output").option("--output-folder <name>", "Deprecated: subfolder name inside --destination").addHelpText(
58
+ "after",
59
+ `
60
+ Examples:
61
+ bulki compress ./photos \u2192 ./bulki-output
62
+ bulki compress ./photos -o ./optimized -f webp
63
+ bulki compress "My Photos" -o "Web Ready" -r 1920
64
+ bulki compress *.png -f avif`
65
+ ).action(async (inputPaths, opts, cmd) => {
66
+ const outputFromCli = cmd.getOptionValueSource("output") === "cli";
45
67
  let options;
46
68
  try {
47
- options = validateOptions(opts);
69
+ options = validateOptions({ ...opts, output: outputFromCli ? opts.output : void 0 });
48
70
  } catch (err) {
49
- console.error(err.message);
50
- process.exit(1);
71
+ fail(err.message);
51
72
  }
52
- const resolvedPath = path.resolve(inputPath);
53
- let filePaths;
54
- let isDirectory;
73
+ const inputs = inputPaths.map((p) => resolveUserPath(p));
74
+ let plan;
55
75
  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);
76
+ plan = await planJobs({
77
+ inputs,
78
+ output: options.output,
79
+ format: options.format,
80
+ exclude: options.exclude
81
+ });
82
+ } catch (err) {
83
+ if (err instanceof PlanError) fail(err.message);
84
+ throw err;
62
85
  }
63
- if (filePaths.length === 0) {
64
- console.log("No images found to process.");
86
+ const log = options.json ? () => {
87
+ } : console.log;
88
+ for (const s of plan.skipped) console.error(`Skipped ${s.file}: ${s.reason}`);
89
+ if (plan.jobs.length === 0) {
90
+ if (options.json) console.log(JSON.stringify(buildJsonOutput([], [], 0, options.output)));
91
+ else log("No images found to process.");
65
92
  return;
66
93
  }
67
94
  if (options.dryRun) {
68
- console.log(`Would process ${filePaths.length} image(s)`);
69
- for (const f of filePaths) console.log(` ${path.basename(f.path)}`);
95
+ log(`Would optimize ${plan.jobs.length} image(s) into ${displayPath(options.output)}`);
96
+ for (const job of plan.jobs) log(` ${displayPath(job.input)} \u2192 ${displayPath(job.output)}`);
70
97
  return;
71
98
  }
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;
99
+ const showBar = !options.verbose && !options.json && process.stdout.isTTY;
100
+ const progress = showBar ? createProgressBar(plan.jobs.length) : null;
101
+ const batch = await runBatch(plan.jobs, options, options.concurrency, (job, result, error) => {
102
+ progress?.tick();
103
+ if (!options.verbose || options.json) return;
104
+ if (result) console.log(formatFileResult(result));
105
+ else console.error(`\u2717 ${displayPath(job.input)}: ${error?.message}`);
106
+ });
107
+ const maxSize = options.maxSize;
108
+ const exceeded = maxSize ? batch.results.filter((r) => r.newSize > maxSize) : [];
102
109
  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
+ const output = buildJsonOutput(batch.results, batch.errors, batch.duration, options.output);
111
+ if (maxSize) {
112
+ for (const f of output.files) if (f.newSize > maxSize) f.exceeded = true;
110
113
  }
111
114
  console.log(JSON.stringify(output));
112
115
  } 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);
116
+ console.log(formatSummary(batch.results, batch.errors, batch.duration));
117
+ if (!options.verbose) {
118
+ for (const e of batch.errors) console.error(`\u2717 ${displayPath(e.file)}: ${e.message}`);
128
119
  }
120
+ if (batch.results.length > 0) console.log(`Saved to ${displayPath(options.output)}`);
121
+ for (const r of exceeded) console.warn(`File exceeds size budget: ${r.outputPath}`);
129
122
  }
123
+ if (batch.errors.length > 0 || exceeded.length > 0) process.exitCode = 1;
130
124
  });
125
+ function fail(message) {
126
+ console.error(`error: ${message}`);
127
+ process.exit(1);
128
+ }
131
129
 
132
130
  // src/commands/mcp.ts
133
131
  import { createRequire } from "module";
134
- import { availableParallelism } from "os";
135
- import path2 from "path";
132
+ import path from "path";
136
133
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
137
134
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
138
135
  import { Command as Command2 } from "commander";
139
136
  import { z } from "zod";
140
137
  var require2 = createRequire(import.meta.url);
141
138
  var { version } = require2("../../package.json");
139
+ function text(payload) {
140
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
141
+ }
142
142
  var mcpCommand = new Command2("mcp").description("Start MCP server for AI editor integration (Claude Code, Cursor, etc.)").action(async () => {
143
- const server = new McpServer({
144
- name: "bulki",
145
- version
146
- });
143
+ const server = new McpServer({ name: "bulki", version });
147
144
  server.tool(
148
145
  "compress",
149
- "Compress and optimize images. Supports JPEG, PNG, WebP, GIF, AVIF.",
146
+ "Compress and optimize images (JPEG, PNG, WebP, GIF, AVIF). Originals are never modified; optimized copies are written to `output`.",
150
147
  {
151
148
  path: z.string().describe("File or directory path to compress"),
152
- quality: z.number().min(1).max(100).default(70).describe("Output quality (1-100)"),
149
+ quality: z.number().int().min(1).max(100).default(70).describe("Output quality (1-100)"),
153
150
  format: z.enum(["jpeg", "png", "webp", "gif", "avif"]).optional().describe("Output format (omit to keep original)"),
154
- resize: z.number().positive().optional().describe("Max width in pixels"),
155
- destination: z.string().default(".").describe("Output directory"),
156
- outputFolder: z.string().default("bulki-output").describe("Output folder name"),
151
+ resize: z.number().int().positive().optional().describe("Max width in pixels (never enlarges)"),
152
+ output: z.string().optional().describe("Folder where optimized images are written (default: ./bulki-output)"),
153
+ destination: z.string().optional().describe("Deprecated: base folder, use `output`"),
154
+ outputFolder: z.string().optional().describe("Deprecated: subfolder name, use `output`"),
157
155
  maxSize: z.string().optional().describe("Max file size budget (e.g., 200kb, 1mb)")
158
156
  },
159
157
  async (params) => {
160
- if (params.outputFolder.includes("/") || params.outputFolder.includes("\\") || params.outputFolder.includes("..")) {
161
- return {
162
- content: [
163
- {
164
- type: "text",
165
- text: JSON.stringify({
166
- error: "Invalid outputFolder: must be a simple directory name"
167
- })
168
- }
169
- ]
170
- };
171
- }
172
- const resolvedPath = path2.resolve(params.path);
173
- let filePaths;
174
- let isDirectory;
175
158
  try {
176
- const collected = await collectImageFiles(resolvedPath, []);
177
- filePaths = collected.files;
178
- isDirectory = collected.isDirectory;
179
- } catch {
180
- return {
181
- content: [
182
- {
183
- type: "text",
184
- text: JSON.stringify({ error: `Path not found: ${resolvedPath}` })
185
- }
186
- ]
187
- };
188
- }
189
- if (filePaths.length === 0) {
190
- return {
191
- content: [
192
- {
193
- type: "text",
194
- text: JSON.stringify({ files: [], summary: { totalFiles: 0 } })
195
- }
196
- ]
197
- };
198
- }
199
- const sourcePath = isDirectory ? resolvedPath : path2.dirname(resolvedPath);
200
- const startTime = performance.now();
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
- const processedResults = await processPool(
218
- filePaths,
219
- async (file) => {
220
- try {
221
- const result = await optimizeImage(file.path, {
222
- quality: params.quality,
223
- format: params.format,
224
- resize: params.resize,
225
- destination: params.destination,
226
- outputFolder: params.outputFolder,
227
- concurrency: 1,
228
- exclude: [],
229
- dryRun: false,
230
- verbose: false,
231
- json: false,
232
- sourcePath,
233
- detectedFormat: file.format
234
- });
235
- return result;
236
- } catch (err) {
237
- errors.push({ file: file.path, message: err.message });
238
- return null;
239
- }
240
- },
241
- Math.min(availableParallelism(), 8)
242
- );
243
- const results = processedResults.filter((r) => r !== null);
244
- const totalDuration = performance.now() - startTime;
245
- const output = buildJsonOutput(results, errors, totalDuration);
246
- if (maxSizeBytes !== void 0) {
247
- for (const file of output.files) {
248
- if (file.newSize > maxSizeBytes) {
249
- file.exceeded = true;
250
- }
159
+ const maxSizeBytes = params.maxSize ? parseSize(params.maxSize) : void 0;
160
+ const output = resolveOutputDir(params);
161
+ const plan = await planJobs({
162
+ inputs: [resolveUserPath(params.path)],
163
+ output,
164
+ format: params.format
165
+ });
166
+ const settings = { quality: params.quality, format: params.format, resize: params.resize };
167
+ const batch = await runBatch(plan.jobs, settings, defaultConcurrency());
168
+ const result = buildJsonOutput(batch.results, batch.errors, batch.duration, output);
169
+ if (maxSizeBytes !== void 0) {
170
+ for (const file of result.files) if (file.newSize > maxSizeBytes) file.exceeded = true;
251
171
  }
172
+ return text(result);
173
+ } catch (err) {
174
+ return text({ error: err.message });
252
175
  }
253
- return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
254
176
  }
255
177
  );
256
178
  server.tool(
257
179
  "detect",
258
180
  "Detect if a file is an image and identify its format",
259
- {
260
- path: z.string().describe("Path to the file")
261
- },
181
+ { path: z.string().describe("Path to the file") },
262
182
  async (params) => {
263
- const resolvedPath = path2.resolve(params.path);
264
- const format = await detectFormat(resolvedPath);
265
- const result = format ? { format, isImage: true } : { format: null, isImage: false };
266
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
183
+ const format = await detectFormat(path.resolve(resolveUserPath(params.path)));
184
+ if (format === UNSUPPORTED) return text({ format: "heic", isImage: true, supported: false });
185
+ return text(format ? { format, isImage: true } : { format: null, isImage: false });
267
186
  }
268
187
  );
269
- const transport = new StdioServerTransport();
270
- await server.connect(transport);
188
+ await server.connect(new StdioServerTransport());
271
189
  });
272
190
 
273
191
  // src/commands/pipe.ts
274
192
  import { Command as Command3 } from "commander";
275
- import sharp from "sharp";
276
- 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) => {
277
- const MAX_INPUT_SIZE = 100 * 1024 * 1024;
193
+ var MAX_INPUT_SIZE = 100 * 1024 * 1024;
194
+ 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)", parseInteger, DEFAULTS.quality).option("-r, --resize <width>", "Max width in px (never enlarges)", parseInteger).action(async (opts) => {
195
+ let settings;
196
+ try {
197
+ settings = validateEncodeSettings(opts);
198
+ } catch (err) {
199
+ return fail2(err.message);
200
+ }
278
201
  const chunks = [];
279
202
  let totalSize = 0;
280
203
  for await (const chunk of process.stdin) {
281
204
  totalSize += chunk.length;
282
- if (totalSize > MAX_INPUT_SIZE) {
283
- process.stderr.write("Error: input exceeds 100 MB limit\n");
284
- process.exit(1);
285
- }
205
+ if (totalSize > MAX_INPUT_SIZE) return fail2("input exceeds 100 MB limit");
286
206
  chunks.push(chunk);
287
207
  }
288
208
  const input = Buffer.concat(chunks);
289
- if (input.length === 0) {
290
- process.stderr.write("Error: no input received on stdin\n");
291
- process.exit(1);
292
- }
293
- const format = opts.format;
294
- const quality = opts.quality;
295
- const resize = opts.resize;
296
- if (!VALID_FORMATS.includes(format)) {
297
- process.stderr.write(`Error: invalid format "${format}"
298
- `);
299
- process.exit(1);
300
- }
301
- const outputFormat = format === "jpg" ? "jpeg" : format;
302
- const formatOptions = getFormatOptions(quality);
209
+ if (input.length === 0) return fail2("no input received on stdin");
303
210
  try {
304
- let pipeline = sharp(input);
305
- if (resize) {
306
- pipeline = pipeline.resize(resize);
307
- }
308
- pipeline = pipeline.toFormat(
309
- outputFormat,
310
- formatOptions[outputFormat] ?? {}
311
- );
312
- const outputBuffer = await pipeline.toBuffer();
313
- process.stdout.write(outputBuffer);
211
+ const detected = sniff(input);
212
+ const { data } = await encodeImage(input, detected, settings);
213
+ process.stdout.write(data);
314
214
  } catch (err) {
315
- process.stderr.write(`Error: ${err.message}
316
- `);
317
- process.exit(1);
215
+ fail2(err.message);
318
216
  }
319
217
  });
218
+ function sniff(buf) {
219
+ if (buf[0] === 255 && buf[1] === 216) return "jpeg";
220
+ if (buf[0] === 137 && buf.toString("latin1", 1, 4) === "PNG") return "png";
221
+ if (buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP")
222
+ return "webp";
223
+ if (buf.toString("latin1", 0, 3) === "GIF") return "gif";
224
+ if (buf.toString("latin1", 4, 8) === "ftyp") {
225
+ const brand = buf.toString("latin1", 8, 12);
226
+ return brand === "avif" || brand === "avis" ? "avif" : UNSUPPORTED;
227
+ }
228
+ return "unknown";
229
+ }
230
+ function fail2(message) {
231
+ process.stderr.write(`Error: ${message}
232
+ `);
233
+ process.exit(1);
234
+ }
320
235
 
321
236
  // src/bin/bulki.ts
322
237
  var require3 = createRequire2(import.meta.url);
323
238
  var { version: version2 } = require3("../../package.json");
324
- if (process.argv.length <= 2) {
325
- const { runInteractive } = await import("../interactive-HBGX4FD6.js");
326
- await runInteractive();
239
+ var args = process.argv.slice(2);
240
+ var COMMANDS = /* @__PURE__ */ new Set(["compress", "hook", "mcp", "pipe", "watch", "help"]);
241
+ var droppedPaths = args.length > 0 && !args[0].startsWith("-") && !COMMANDS.has(args[0]) ? parsePathInput(args.join(" ")) : null;
242
+ if (args.length === 0 || droppedPaths?.ok) {
243
+ const { runInteractive } = await import("../interactive-MTL4CMVQ.js");
244
+ await runInteractive(droppedPaths?.ok ? droppedPaths.paths : void 0);
327
245
  } else {
328
246
  const program = new Command4();
329
- program.name("bulki").description("Ultra-fast CLI image optimizer").version(version2);
247
+ program.name("bulki").description("Fast image optimizer. Run `bulki` with no arguments for the guided mode.").version(version2).showSuggestionAfterError();
330
248
  program.addCommand(compressCommand);
331
249
  program.addCommand(hookCommand);
332
250
  program.addCommand(mcpCommand);
333
251
  program.addCommand(pipeCommand);
334
252
  program.addCommand(watchCommand);
335
- program.parse();
253
+ await program.parseAsync();
336
254
  }
337
255
  //# sourceMappingURL=bulki.js.map