heysnap-image-gen 0.1.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,56 @@
1
+ # image-gen
2
+
3
+ Opinionated GPT Image 2 CLI for VM agents.
4
+
5
+ `image-gen` sends image generation and image edit requests through the HeySnap
6
+ gateway, reads the VM machine token automatically, and writes the resulting
7
+ image files locally.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install -g heysnap-image-gen
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```sh
18
+ image-gen "A polished product photo of a matte black coffee mug" -o mug.png
19
+ ```
20
+
21
+ Use a reference image:
22
+
23
+ ```sh
24
+ image-gen "Restyle this screenshot as a polished SaaS dashboard" \
25
+ --image screenshot.png \
26
+ -o dashboard.png
27
+ ```
28
+
29
+ Generate multiple images:
30
+
31
+ ```sh
32
+ image-gen "Three calm finance app logo concepts" --n 3 --out-dir logos
33
+ ```
34
+
35
+ Read a long prompt from a file:
36
+
37
+ ```sh
38
+ image-gen --prompt-file prompt.txt -o result.png
39
+ ```
40
+
41
+ ## Auth
42
+
43
+ The CLI checks token sources in this order:
44
+
45
+ 1. `ANK1015_IMAGE_GATEWAY_TOKEN`
46
+ 2. `ANK1015_CODEX_GATEWAY_TOKEN`
47
+ 3. `ANK1015_MACHINE_TOKEN_FILE`
48
+ 4. `/opt/ank1015/machine-token`
49
+
50
+ The default gateway base URL is:
51
+
52
+ ```text
53
+ https://api.heysnap.xyz/llm/openai/v1
54
+ ```
55
+
56
+ See [docs/usage.md](docs/usage.md) for the full CLI contract.
package/dist/cli.js ADDED
@@ -0,0 +1,426 @@
1
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ const MODEL = "gpt-image-2";
4
+ const DEFAULT_GATEWAY_BASE_URL = "https://api.heysnap.xyz/llm/openai/v1";
5
+ const DEFAULT_MACHINE_TOKEN_FILE = "/opt/ank1015/machine-token";
6
+ const MAX_INPUT_IMAGES = 16;
7
+ const MAX_OUTPUT_IMAGES = 10;
8
+ const MIN_PIXELS = 655_360;
9
+ const MAX_PIXELS = 8_294_400;
10
+ const MAX_EDGE = 3840;
11
+ class CliError extends Error {
12
+ }
13
+ export const runCli = async (argv, runtime = {}) => {
14
+ const stdout = runtime.stdout ?? process.stdout;
15
+ const stderr = runtime.stderr ?? process.stderr;
16
+ try {
17
+ const parsed = await parseArgs(argv, runtime);
18
+ if (parsed.help === true) {
19
+ stdout.write(`${helpText()}\n`);
20
+ return 0;
21
+ }
22
+ if (parsed.options === undefined) {
23
+ throw new CliError("Missing prompt.");
24
+ }
25
+ const result = await generateImages(parsed.options, runtime);
26
+ stdout.write(`${result.paths.join("\n")}\n`);
27
+ return 0;
28
+ }
29
+ catch (error) {
30
+ stderr.write(`image-gen: ${error instanceof Error ? error.message : String(error)}\n`);
31
+ return 1;
32
+ }
33
+ };
34
+ export const parseArgs = async (argv, runtime = {}) => {
35
+ const env = runtime.env ?? process.env;
36
+ const cwd = runtime.cwd ?? process.cwd();
37
+ const promptParts = [];
38
+ const images = [];
39
+ let output;
40
+ let outDir;
41
+ let mask;
42
+ let size = "auto";
43
+ let quality = "auto";
44
+ let format;
45
+ let compression;
46
+ let n = 1;
47
+ let moderation = "auto";
48
+ let promptFile;
49
+ for (let index = 0; index < argv.length; index += 1) {
50
+ const arg = argv[index] ?? "";
51
+ if (arg === "--help" || arg === "-h") {
52
+ return { help: true };
53
+ }
54
+ if (arg === "--") {
55
+ promptParts.push(...argv.slice(index + 1));
56
+ break;
57
+ }
58
+ if (!arg.startsWith("-")) {
59
+ promptParts.push(arg);
60
+ continue;
61
+ }
62
+ switch (arg) {
63
+ case "-o":
64
+ case "--output":
65
+ output = requireValue(argv, index, arg);
66
+ index += 1;
67
+ break;
68
+ case "--out-dir":
69
+ outDir = requireValue(argv, index, arg);
70
+ index += 1;
71
+ break;
72
+ case "-i":
73
+ case "--image":
74
+ images.push(requireValue(argv, index, arg));
75
+ index += 1;
76
+ break;
77
+ case "--mask":
78
+ mask = requireValue(argv, index, arg);
79
+ index += 1;
80
+ break;
81
+ case "--size":
82
+ size = requireValue(argv, index, arg);
83
+ index += 1;
84
+ break;
85
+ case "--quality":
86
+ quality = parseEnum(requireValue(argv, index, arg), ["auto", "low", "medium", "high"], "--quality");
87
+ index += 1;
88
+ break;
89
+ case "--format":
90
+ format = parseEnum(requireValue(argv, index, arg), ["png", "jpeg"], "--format");
91
+ index += 1;
92
+ break;
93
+ case "--compression":
94
+ compression = parseInteger(requireValue(argv, index, arg), "--compression", 0, 100);
95
+ index += 1;
96
+ break;
97
+ case "--n":
98
+ n = parseInteger(requireValue(argv, index, arg), "--n", 1, MAX_OUTPUT_IMAGES);
99
+ index += 1;
100
+ break;
101
+ case "--moderation":
102
+ moderation = parseEnum(requireValue(argv, index, arg), ["auto", "low"], "--moderation");
103
+ index += 1;
104
+ break;
105
+ case "--prompt-file":
106
+ promptFile = requireValue(argv, index, arg);
107
+ index += 1;
108
+ break;
109
+ default:
110
+ throw new CliError(`Unknown option: ${arg}`);
111
+ }
112
+ }
113
+ if (promptFile !== undefined && promptParts.length > 0) {
114
+ throw new CliError("Use either a prompt argument or --prompt-file, not both.");
115
+ }
116
+ const prompt = promptFile === undefined
117
+ ? promptParts.join(" ").trim()
118
+ : (await readFile(resolve(cwd, promptFile), "utf8")).trim();
119
+ const resolvedFormat = resolveOutputFormat({ explicitFormat: format, output });
120
+ const options = {
121
+ prompt,
122
+ output,
123
+ outDir,
124
+ images,
125
+ mask,
126
+ size,
127
+ quality,
128
+ format: resolvedFormat,
129
+ compression,
130
+ n,
131
+ moderation,
132
+ };
133
+ validateOptions(options, env);
134
+ return { options };
135
+ };
136
+ export const generateImages = async (options, runtime = {}) => {
137
+ const env = runtime.env ?? process.env;
138
+ const cwd = runtime.cwd ?? process.cwd();
139
+ const fetchImpl = runtime.fetch ?? fetch;
140
+ const token = await readMachineToken(env);
141
+ const baseUrl = resolveGatewayBaseUrl(env);
142
+ const request = options.images.length === 0
143
+ ? await buildGenerationRequest(options, baseUrl, token)
144
+ : await buildEditRequest(options, baseUrl, token, cwd);
145
+ const response = await fetchImpl(request.url, request.init);
146
+ const body = await readResponseJson(response);
147
+ if (!response.ok) {
148
+ throw new CliError(readApiError(response.status, body));
149
+ }
150
+ const imageResponse = body;
151
+ const base64Images = imageResponse.data?.map((entry) => entry.b64_json).filter(isString) ?? [];
152
+ if (base64Images.length === 0) {
153
+ throw new CliError("Gateway response did not include any image data.");
154
+ }
155
+ const outputPaths = buildOutputPaths(options, base64Images.length, cwd, runtime.now?.() ?? new Date());
156
+ await Promise.all(outputPaths.map(async (outputPath, index) => {
157
+ await mkdir(dirname(outputPath), { recursive: true });
158
+ await writeFile(outputPath, Buffer.from(base64Images[index] ?? "", "base64"));
159
+ }));
160
+ return { paths: outputPaths };
161
+ };
162
+ const buildGenerationRequest = async (options, baseUrl, token) => {
163
+ const body = commonPayload(options);
164
+ return {
165
+ url: `${baseUrl}/images/generations`,
166
+ init: {
167
+ method: "POST",
168
+ headers: {
169
+ "api-key": token,
170
+ "content-type": "application/json",
171
+ },
172
+ body: JSON.stringify(body),
173
+ },
174
+ };
175
+ };
176
+ const buildEditRequest = async (options, baseUrl, token, cwd) => {
177
+ const form = new FormData();
178
+ const payload = commonPayload(options);
179
+ for (const [key, value] of Object.entries(payload)) {
180
+ form.set(key, String(value));
181
+ }
182
+ for (const imagePath of options.images) {
183
+ form.append("image[]", await pathToBlob(resolve(cwd, imagePath)), basename(imagePath));
184
+ }
185
+ if (options.mask !== undefined) {
186
+ form.set("mask", await pathToBlob(resolve(cwd, options.mask)), basename(options.mask));
187
+ }
188
+ return {
189
+ url: `${baseUrl}/images/edits`,
190
+ init: {
191
+ method: "POST",
192
+ headers: {
193
+ "api-key": token,
194
+ },
195
+ body: form,
196
+ },
197
+ };
198
+ };
199
+ const commonPayload = (options) => {
200
+ const payload = {
201
+ model: MODEL,
202
+ prompt: options.prompt,
203
+ size: options.size,
204
+ quality: options.quality,
205
+ output_format: options.format ?? "png",
206
+ n: options.n,
207
+ moderation: options.moderation,
208
+ };
209
+ if (options.compression !== undefined) {
210
+ payload["output_compression"] = options.compression;
211
+ }
212
+ return payload;
213
+ };
214
+ const validateOptions = (options, env) => {
215
+ if (options.prompt.length === 0) {
216
+ throw new CliError("Prompt is required.");
217
+ }
218
+ if (options.prompt.length > 32_000) {
219
+ throw new CliError("Prompt must be 32,000 characters or fewer for GPT image models.");
220
+ }
221
+ if (options.output !== undefined && options.outDir !== undefined) {
222
+ throw new CliError("Use either --output or --out-dir, not both.");
223
+ }
224
+ if (options.n > 1 && options.output !== undefined) {
225
+ throw new CliError("Use --out-dir instead of --output when --n is greater than 1.");
226
+ }
227
+ if (options.images.length > MAX_INPUT_IMAGES) {
228
+ throw new CliError(`--image can be repeated up to ${String(MAX_INPUT_IMAGES)} times.`);
229
+ }
230
+ if (options.mask !== undefined && options.images.length === 0) {
231
+ throw new CliError("--mask requires at least one --image.");
232
+ }
233
+ validateSize(options.size);
234
+ if (options.compression !== undefined && (options.format ?? "png") === "png") {
235
+ throw new CliError("--compression is only valid with --format jpeg.");
236
+ }
237
+ const tokenSources = [
238
+ env["ANK1015_IMAGE_GATEWAY_TOKEN"],
239
+ env["ANK1015_CODEX_GATEWAY_TOKEN"],
240
+ env["ANK1015_MACHINE_TOKEN_FILE"],
241
+ ];
242
+ if (tokenSources.every((value) => value === undefined || value.trim().length === 0)) {
243
+ // The default VM token path may still exist; defer this to runtime so local tests can inject env cleanly.
244
+ return;
245
+ }
246
+ };
247
+ const validateSize = (size) => {
248
+ if (size === "auto") {
249
+ return;
250
+ }
251
+ const match = /^(\d+)x(\d+)$/.exec(size);
252
+ if (match === null) {
253
+ throw new CliError("--size must be auto or WIDTHxHEIGHT, for example 1024x1024.");
254
+ }
255
+ const width = Number(match[1]);
256
+ const height = Number(match[2]);
257
+ const longEdge = Math.max(width, height);
258
+ const shortEdge = Math.min(width, height);
259
+ const pixels = width * height;
260
+ if (width % 16 !== 0 || height % 16 !== 0) {
261
+ throw new CliError("--size width and height must both be multiples of 16.");
262
+ }
263
+ if (longEdge > MAX_EDGE) {
264
+ throw new CliError(`--size maximum edge length is ${String(MAX_EDGE)}px.`);
265
+ }
266
+ if (longEdge / shortEdge > 3) {
267
+ throw new CliError("--size long edge to short edge ratio must not exceed 3:1.");
268
+ }
269
+ if (pixels < MIN_PIXELS || pixels > MAX_PIXELS) {
270
+ throw new CliError(`--size total pixels must be between ${String(MIN_PIXELS)} and ${String(MAX_PIXELS)}.`);
271
+ }
272
+ };
273
+ const buildOutputPaths = (options, count, cwd, now) => {
274
+ if (options.output !== undefined) {
275
+ return [resolve(cwd, options.output)];
276
+ }
277
+ const outputDir = resolve(cwd, options.outDir ?? ".");
278
+ const stamp = toFileTimestamp(now);
279
+ const extension = options.format ?? "png";
280
+ return Array.from({ length: count }, (_, index) => (join(outputDir, `image-${stamp}-${String(index + 1).padStart(2, "0")}.${extension}`)));
281
+ };
282
+ const resolveOutputFormat = (input) => {
283
+ const outputExtension = input.output === undefined ? undefined : extname(input.output);
284
+ const extension = outputExtension === undefined ? undefined : extensionToFormat(outputExtension);
285
+ if (outputExtension !== undefined && outputExtension.length > 0 && extension === undefined) {
286
+ throw new CliError(`Unsupported output extension ${outputExtension}. Use .png, .jpg, or .jpeg.`);
287
+ }
288
+ if (input.explicitFormat !== undefined && extension !== undefined && input.explicitFormat !== extension) {
289
+ throw new CliError(`--format ${input.explicitFormat} does not match output extension ${outputExtension}.`);
290
+ }
291
+ return input.explicitFormat ?? extension ?? "png";
292
+ };
293
+ const extensionToFormat = (extension) => {
294
+ switch (extension.toLowerCase()) {
295
+ case ".png":
296
+ return "png";
297
+ case ".jpg":
298
+ case ".jpeg":
299
+ return "jpeg";
300
+ default:
301
+ return undefined;
302
+ }
303
+ };
304
+ const pathToBlob = async (path) => {
305
+ const fileStat = await stat(path);
306
+ if (fileStat.size > 50 * 1024 * 1024) {
307
+ throw new CliError(`Image files must be 50MB or smaller: ${path}`);
308
+ }
309
+ return new Blob([await readFile(path)], { type: contentTypeForPath(path) });
310
+ };
311
+ const contentTypeForPath = (path) => {
312
+ switch (extname(path).toLowerCase()) {
313
+ case ".png":
314
+ return "image/png";
315
+ case ".jpg":
316
+ case ".jpeg":
317
+ return "image/jpeg";
318
+ case ".webp":
319
+ return "image/webp";
320
+ default:
321
+ return "application/octet-stream";
322
+ }
323
+ };
324
+ const readMachineToken = async (env) => {
325
+ const inlineToken = env["ANK1015_IMAGE_GATEWAY_TOKEN"]?.trim()
326
+ || env["ANK1015_CODEX_GATEWAY_TOKEN"]?.trim();
327
+ if (inlineToken !== undefined && inlineToken.length > 0) {
328
+ return inlineToken;
329
+ }
330
+ const tokenFile = env["ANK1015_MACHINE_TOKEN_FILE"]?.trim() || DEFAULT_MACHINE_TOKEN_FILE;
331
+ try {
332
+ const token = (await readFile(tokenFile, "utf8")).trim();
333
+ if (token.length > 0) {
334
+ return token;
335
+ }
336
+ }
337
+ catch {
338
+ // The specific error below is clearer for agents than the fs failure.
339
+ }
340
+ throw new CliError(`Machine token not found. Set ANK1015_IMAGE_GATEWAY_TOKEN or ANK1015_MACHINE_TOKEN_FILE.`);
341
+ };
342
+ const resolveGatewayBaseUrl = (env) => {
343
+ const explicit = env["ANK1015_IMAGE_GATEWAY_URL"]?.trim();
344
+ if (explicit !== undefined && explicit.length > 0) {
345
+ return explicit.replace(/\/+$/, "");
346
+ }
347
+ const cloudServer = env["CLOUD_SERVER_PUBLIC_URL"]?.trim();
348
+ if (cloudServer !== undefined && cloudServer.length > 0) {
349
+ return `${cloudServer.replace(/\/+$/, "")}/llm/openai/v1`;
350
+ }
351
+ return DEFAULT_GATEWAY_BASE_URL;
352
+ };
353
+ const readResponseJson = async (response) => {
354
+ const text = await response.text();
355
+ if (text.trim().length === 0) {
356
+ return null;
357
+ }
358
+ try {
359
+ return JSON.parse(text);
360
+ }
361
+ catch {
362
+ throw new CliError(`Gateway returned invalid JSON with HTTP ${String(response.status)}.`);
363
+ }
364
+ };
365
+ const readApiError = (status, body) => {
366
+ if (typeof body === "object" && body !== null && "error" in body) {
367
+ const error = body.error;
368
+ const message = typeof error?.message === "string" ? error.message : undefined;
369
+ const code = typeof error?.code === "string" ? error.code : undefined;
370
+ if (message !== undefined && code !== undefined) {
371
+ return `Gateway request failed (${String(status)} ${code}): ${message}`;
372
+ }
373
+ if (message !== undefined) {
374
+ return `Gateway request failed (${String(status)}): ${message}`;
375
+ }
376
+ }
377
+ return `Gateway request failed with HTTP ${String(status)}.`;
378
+ };
379
+ const requireValue = (argv, index, option) => {
380
+ const value = argv[index + 1];
381
+ if (value === undefined || value.length === 0 || value.startsWith("-")) {
382
+ throw new CliError(`${option} requires a value.`);
383
+ }
384
+ return value;
385
+ };
386
+ const parseInteger = (value, option, min, max) => {
387
+ if (!/^\d+$/.test(value)) {
388
+ throw new CliError(`${option} must be an integer.`);
389
+ }
390
+ const parsed = Number(value);
391
+ if (parsed < min || parsed > max) {
392
+ throw new CliError(`${option} must be between ${String(min)} and ${String(max)}.`);
393
+ }
394
+ return parsed;
395
+ };
396
+ const parseEnum = (value, allowed, option) => {
397
+ if (!allowed.includes(value)) {
398
+ throw new CliError(`${option} must be one of: ${allowed.join(", ")}.`);
399
+ }
400
+ return value;
401
+ };
402
+ const toFileTimestamp = (date) => date.toISOString().replace(/\.\d{3}Z$/, "Z").replace(/[:-]/g, "").replace("T", "-");
403
+ const isString = (value) => typeof value === "string" && value.length > 0;
404
+ const helpText = () => `Usage:
405
+ image-gen <prompt> [options]
406
+ image-gen --prompt-file prompt.txt [options]
407
+
408
+ Examples:
409
+ image-gen "A polished product photo of a matte black coffee mug" -o mug.png
410
+ image-gen "Restyle this screenshot as a clean SaaS dashboard" -i screenshot.png -o dashboard.png
411
+ image-gen "Replace the masked area with a small indoor pool" -i room.png --mask mask.png -o room-edit.png
412
+ image-gen "Three calm finance app logo concepts" --n 3 --out-dir logos
413
+
414
+ Options:
415
+ -o, --output <path> Output file for one image
416
+ --out-dir <dir> Output directory, required when --n > 1
417
+ -i, --image <path> Reference/input image, repeatable up to 16 times
418
+ --mask <path> Mask image for editing the first input image
419
+ --size <size> auto or WIDTHxHEIGHT, default: auto
420
+ --quality <quality> auto | low | medium | high, default: auto
421
+ --format <format> png | jpeg, default: output extension or png
422
+ --compression <0-100> jpeg only
423
+ --n <count> Number of images, 1-10, default: 1
424
+ --moderation <value> auto | low, default: auto
425
+ --prompt-file <path> Read prompt from file
426
+ -h, --help Show this help`;
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from "./cli.js";
3
+ process.exitCode = await runCli(process.argv.slice(2));
package/docs/usage.md ADDED
@@ -0,0 +1,214 @@
1
+ # image-gen CLI
2
+
3
+ `image-gen` is an opinionated image generation CLI for VM agents. It always uses
4
+ `gpt-image-2`, sends requests through the HeySnap gateway, handles machine-token
5
+ auth automatically, and writes image files locally.
6
+
7
+ The core contract is:
8
+
9
+ ```sh
10
+ image-gen <prompt> [options]
11
+ ```
12
+
13
+ It returns the generated image path or paths on `stdout`, one path per line. Use
14
+ those paths directly in scripts or agent workflows.
15
+
16
+ ```sh
17
+ image_path="$(image-gen "A clean product photo of a matte black coffee mug" -o mug.png)"
18
+ ```
19
+
20
+ ## Common Commands
21
+
22
+ Generate one image from a text prompt:
23
+
24
+ ```sh
25
+ image-gen "A polished product photo of a matte black coffee mug on a white desk" \
26
+ -o mug.png
27
+ ```
28
+
29
+ Generate to an automatically named file in the current directory:
30
+
31
+ ```sh
32
+ image-gen "A minimal app icon for a calendar app"
33
+ ```
34
+
35
+ Generate multiple images:
36
+
37
+ ```sh
38
+ image-gen "Three calm finance app logo concepts, premium and simple" \
39
+ --n 3 \
40
+ --out-dir logos
41
+ ```
42
+
43
+ Use one reference image:
44
+
45
+ ```sh
46
+ image-gen "Restyle this screenshot as a polished SaaS dashboard" \
47
+ --image screenshot.png \
48
+ -o dashboard.png
49
+ ```
50
+
51
+ Use multiple reference images:
52
+
53
+ ```sh
54
+ image-gen "Create a gift basket containing the items from these references" \
55
+ --image lotion.png \
56
+ --image candle.png \
57
+ --image soap.png \
58
+ --image ribbon.png \
59
+ -o basket.png
60
+ ```
61
+
62
+ Edit an image with a mask:
63
+
64
+ ```sh
65
+ image-gen "Replace the masked area with a small indoor pool" \
66
+ --image room.png \
67
+ --mask mask.png \
68
+ -o room-edit.png
69
+ ```
70
+
71
+ Read a long prompt from a file:
72
+
73
+ ```sh
74
+ image-gen --prompt-file prompt.txt -o result.png
75
+ ```
76
+
77
+ Request JPEG output:
78
+
79
+ ```sh
80
+ image-gen "A fast-loading article hero image of a modern workspace" \
81
+ --format jpeg \
82
+ --compression 65 \
83
+ -o hero.jpg
84
+ ```
85
+
86
+ ## Options
87
+
88
+ ```text
89
+ -o, --output <path> Output file for one image
90
+ --out-dir <dir> Output directory, required when --n > 1
91
+ -i, --image <path> Reference/input image, repeatable up to 16 times
92
+ --mask <path> Mask image for editing the first input image
93
+ --size <size> auto or WIDTHxHEIGHT, default: auto
94
+ --quality <quality> auto | low | medium | high, default: auto
95
+ --format <format> png | jpeg | webp, default: output extension or png
96
+ --compression <0-100> jpeg/webp only
97
+ --n <count> Number of images, 1-10, default: 1
98
+ --moderation <value> auto | low, default: auto
99
+ --prompt-file <path> Read prompt from file
100
+ -h, --help Show help
101
+ ```
102
+
103
+ ## Behavior
104
+
105
+ If no `--image` is passed, `image-gen` creates a new image from the text prompt.
106
+
107
+ If one or more `--image` flags are passed, `image-gen` sends an edit/reference
108
+ image request. The prompt should describe the desired output, not just the input.
109
+
110
+ If `--mask` is passed, the mask applies to the first `--image`. The mask and
111
+ first image should have the same dimensions. Use a PNG mask with an alpha
112
+ channel.
113
+
114
+ If `--output` is omitted, files are named like:
115
+
116
+ ```text
117
+ image-20260509-101112Z-01.png
118
+ ```
119
+
120
+ If `--n` is greater than `1`, use `--out-dir` instead of `--output`.
121
+
122
+ ## Size
123
+
124
+ `--size` defaults to `auto`.
125
+
126
+ You can also pass explicit dimensions:
127
+
128
+ ```sh
129
+ image-gen "A cinematic landscape poster" --size 1536x1024 -o poster.png
130
+ image-gen "A square app icon" --size 1024x1024 -o icon.png
131
+ image-gen "A portrait poster" --size 1024x1536 -o portrait.png
132
+ ```
133
+
134
+ Explicit sizes must follow GPT Image 2 constraints:
135
+
136
+ - Width and height must be multiples of `16`.
137
+ - Maximum edge length is `3840px`.
138
+ - Long edge to short edge ratio must not exceed `3:1`.
139
+ - Total pixels must be between `655360` and `8294400`.
140
+
141
+ ## Quality
142
+
143
+ `--quality` defaults to `auto`.
144
+
145
+ Use `low` for fast drafts, `medium` for balanced output, and `high` for final
146
+ assets:
147
+
148
+ ```sh
149
+ image-gen "A rough thumbnail concept for a travel app" --quality low -o draft.png
150
+ image-gen "A final polished app-store hero graphic" --quality high -o final.png
151
+ ```
152
+
153
+ ## Format And Compression
154
+
155
+ Supported formats:
156
+
157
+ - `png`
158
+ - `jpeg`
159
+
160
+ If `--format` is omitted and `--output` has a known extension, the extension is
161
+ used. For example, `-o hero.jpg` requests JPEG.
162
+
163
+ `--compression` is only valid with `jpeg`:
164
+
165
+ ```sh
166
+ image-gen "A blog cover image" --format jpeg --compression 70 -o cover.jpg
167
+ ```
168
+
169
+ ## Moderation
170
+
171
+ `--moderation` controls GPT Image moderation strictness:
172
+
173
+ - `auto`: default filtering
174
+ - `low`: less restrictive filtering
175
+
176
+ ```sh
177
+ image-gen "A dramatic editorial fashion image" --moderation auto -o fashion.png
178
+ ```
179
+
180
+ ## Limits
181
+
182
+ - Prompt length: up to `32000` characters.
183
+ - Reference images: up to `16` `--image` flags.
184
+ - Output count: `--n` supports `1` through `10`.
185
+ - Input image files: `50MB` or smaller.
186
+ - Transparent backgrounds are not exposed because this CLI is scoped to
187
+ `gpt-image-2` only.
188
+ - The model is fixed to `gpt-image-2`; there is no `--model` flag.
189
+
190
+ ## Script Usage
191
+
192
+ Use `stdout` for paths:
193
+
194
+ ```sh
195
+ result="$(image-gen "A small pixel-art save icon" -o save-icon.png)"
196
+ echo "Generated: $result"
197
+ ```
198
+
199
+ Multiple outputs:
200
+
201
+ ```sh
202
+ image-gen "Four onboarding illustrations for a productivity app" \
203
+ --n 4 \
204
+ --out-dir onboarding \
205
+ > generated-images.txt
206
+ ```
207
+
208
+ Then read each path:
209
+
210
+ ```sh
211
+ while read -r path; do
212
+ echo "Generated $path"
213
+ done < generated-images.txt
214
+ ```
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "heysnap-image-gen",
3
+ "version": "0.1.0",
4
+ "description": "Opinionated GPT Image 2 CLI for VM agents.",
5
+ "type": "module",
6
+ "bin": {
7
+ "image-gen": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.json",
16
+ "dev": "node --import tsx src/index.ts",
17
+ "start": "node dist/index.js",
18
+ "test": "node --import tsx --test src/**/*.test.ts",
19
+ "prepack": "npm run build",
20
+ "prepublishOnly": "npm run build && npm test"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^25.6.2",
24
+ "tsx": "^4.21.0",
25
+ "typescript": "^6.0.3"
26
+ }
27
+ }