pattyeng 1.0.3 → 1.0.6

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.
Files changed (99) hide show
  1. package/README.md +90 -55
  2. package/bin/pattyeng.js +20 -14
  3. package/lib/skills.js +170 -0
  4. package/lib/upgrade.js +46 -0
  5. package/package.json +5 -3
  6. package/skills/.system/.codex-system-skills.marker +1 -0
  7. package/skills/.system/imagegen/LICENSE.txt +201 -0
  8. package/skills/.system/imagegen/SKILL.md +356 -0
  9. package/skills/.system/imagegen/agents/openai.yaml +6 -0
  10. package/skills/.system/imagegen/assets/imagegen-small.svg +5 -0
  11. package/skills/.system/imagegen/assets/imagegen.png +0 -0
  12. package/skills/.system/imagegen/references/cli.md +242 -0
  13. package/skills/.system/imagegen/references/codex-network.md +33 -0
  14. package/skills/.system/imagegen/references/image-api.md +90 -0
  15. package/skills/.system/imagegen/references/prompting.md +118 -0
  16. package/skills/.system/imagegen/references/sample-prompts.md +433 -0
  17. package/skills/.system/imagegen/scripts/image_gen.py +995 -0
  18. package/skills/.system/imagegen/scripts/remove_chroma_key.py +440 -0
  19. package/skills/.system/openai-docs/LICENSE.txt +201 -0
  20. package/skills/.system/openai-docs/SKILL.md +167 -0
  21. package/skills/.system/openai-docs/agents/openai.yaml +14 -0
  22. package/skills/.system/openai-docs/assets/openai-small.svg +3 -0
  23. package/skills/.system/openai-docs/assets/openai.png +0 -0
  24. package/skills/.system/openai-docs/references/latest-model.md +37 -0
  25. package/skills/.system/openai-docs/references/prompting-guide.md +244 -0
  26. package/skills/.system/openai-docs/references/upgrade-guide.md +181 -0
  27. package/skills/.system/openai-docs/scripts/fetch-codex-manual.mjs +598 -0
  28. package/skills/.system/openai-docs/scripts/resolve-latest-model-info.js +147 -0
  29. package/skills/.system/plugin-creator/SKILL.md +243 -0
  30. package/skills/.system/plugin-creator/agents/openai.yaml +6 -0
  31. package/skills/.system/plugin-creator/assets/plugin-creator-small.svg +3 -0
  32. package/skills/.system/plugin-creator/assets/plugin-creator.png +0 -0
  33. package/skills/.system/plugin-creator/references/installing-and-updating.md +143 -0
  34. package/skills/.system/plugin-creator/references/plugin-json-spec.md +194 -0
  35. package/skills/.system/plugin-creator/scripts/create_basic_plugin.py +324 -0
  36. package/skills/.system/plugin-creator/scripts/read_marketplace_name.py +48 -0
  37. package/skills/.system/plugin-creator/scripts/update_plugin_cachebuster.py +78 -0
  38. package/skills/.system/plugin-creator/scripts/validate_plugin.py +586 -0
  39. package/skills/.system/skill-creator/SKILL.md +416 -0
  40. package/skills/.system/skill-creator/agents/openai.yaml +5 -0
  41. package/skills/.system/skill-creator/assets/skill-creator-small.svg +3 -0
  42. package/skills/.system/skill-creator/assets/skill-creator.png +0 -0
  43. package/skills/.system/skill-creator/license.txt +202 -0
  44. package/skills/.system/skill-creator/references/openai_yaml.md +49 -0
  45. package/skills/.system/skill-creator/scripts/generate_openai_yaml.py +226 -0
  46. package/skills/.system/skill-creator/scripts/init_skill.py +400 -0
  47. package/skills/.system/skill-creator/scripts/quick_validate.py +101 -0
  48. package/skills/.system/skill-installer/LICENSE.txt +202 -0
  49. package/skills/.system/skill-installer/SKILL.md +58 -0
  50. package/skills/.system/skill-installer/agents/openai.yaml +5 -0
  51. package/skills/.system/skill-installer/assets/skill-installer-small.svg +3 -0
  52. package/skills/.system/skill-installer/assets/skill-installer.png +0 -0
  53. package/skills/.system/skill-installer/scripts/github_utils.py +21 -0
  54. package/skills/.system/skill-installer/scripts/install-skill-from-github.py +308 -0
  55. package/skills/.system/skill-installer/scripts/list-skills.py +107 -0
  56. package/skills/cap/SKILL.md +77 -0
  57. package/skills/caveman/SKILL.md +49 -0
  58. package/skills/code-review/SKILL.md +46 -0
  59. package/skills/codebook/SKILL.md +184 -0
  60. package/skills/doc-review/SKILL.md +147 -0
  61. package/skills/feature-research/SKILL.md +34 -0
  62. package/skills/file-op/SKILL.md +116 -0
  63. package/skills/grill-me/SKILL.md +10 -0
  64. package/skills/grill-with-docs/ADR-FORMAT.md +47 -0
  65. package/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
  66. package/skills/grill-with-docs/SKILL.md +88 -0
  67. package/skills/handoff/SKILL.md +17 -0
  68. package/skills/improve-codebase/SKILL.md +79 -0
  69. package/skills/linear/SKILL.md +107 -0
  70. package/skills/next-step/SKILL.md +61 -0
  71. package/skills/no-reinvent/SKILL.md +131 -0
  72. package/skills/no-wall/SKILL.md +146 -0
  73. package/skills/online/SKILL.md +54 -0
  74. package/skills/plan-review/SKILL.md +55 -0
  75. package/skills/professional/SKILL.md +111 -0
  76. package/skills/promise-tracking/SKILL.md +131 -0
  77. package/skills/prove/SKILL.md +135 -0
  78. package/skills/read-before-write/SKILL.md +123 -0
  79. package/skills/review/SKILL.md +78 -0
  80. package/skills/review-mp/SKILL.md +78 -0
  81. package/skills/scope-boundary/SKILL.md +121 -0
  82. package/skills/semble-search/SKILL.md +40 -0
  83. package/skills/sitrep/SKILL.md +126 -0
  84. package/skills/structure-code/SKILL.md +116 -0
  85. package/skills/surface-assumptions/SKILL.md +139 -0
  86. package/skills/teach/GLOSSARY-FORMAT.md +35 -0
  87. package/skills/teach/LEARNING-RECORD-FORMAT.md +46 -0
  88. package/skills/teach/MISSION-FORMAT.md +31 -0
  89. package/skills/teach/RESOURCES-FORMAT.md +32 -0
  90. package/skills/teach/SKILL.md +131 -0
  91. package/skills/team-protocol/SKILL.md +172 -0
  92. package/skills/tech-lead/SKILL.md +332 -0
  93. package/skills/to-issues/SKILL.md +83 -0
  94. package/skills/to-prd/SKILL.md +74 -0
  95. package/skills/vectorbt-expert/SKILL.md +253 -0
  96. package/skills/verify-done/SKILL.md +128 -0
  97. package/skills/work-log/SKILL.md +141 -0
  98. package/skills/work-log/template.html +496 -0
  99. package/skills/zoom-out/SKILL.md +7 -0
@@ -0,0 +1,995 @@
1
+ #!/usr/bin/env python3
2
+ """Fallback CLI for explicit image generation or editing with GPT Image models.
3
+
4
+ Used only when the user explicitly opts into CLI fallback mode, or when explicit
5
+ transparent output requires the `gpt-image-1.5` fallback path.
6
+
7
+ Defaults to gpt-image-2 and a structured prompt augmentation workflow.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import base64
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ import re
19
+ import sys
20
+ import time
21
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
22
+
23
+ from io import BytesIO
24
+
25
+ DEFAULT_MODEL = "gpt-image-2"
26
+ DEFAULT_SIZE = "auto"
27
+ DEFAULT_QUALITY = "medium"
28
+ DEFAULT_OUTPUT_FORMAT = "png"
29
+ DEFAULT_CONCURRENCY = 5
30
+ DEFAULT_DOWNSCALE_SUFFIX = "-web"
31
+ DEFAULT_OUTPUT_PATH = "output/imagegen/output.png"
32
+ GPT_IMAGE_MODEL_PREFIX = "gpt-image-"
33
+
34
+ ALLOWED_LEGACY_SIZES = {"1024x1024", "1536x1024", "1024x1536", "auto"}
35
+ ALLOWED_QUALITIES = {"low", "medium", "high", "auto"}
36
+ ALLOWED_BACKGROUNDS = {"transparent", "opaque", "auto", None}
37
+ ALLOWED_INPUT_FIDELITIES = {"low", "high", None}
38
+
39
+ GPT_IMAGE_2_MODEL = "gpt-image-2"
40
+ GPT_IMAGE_2_MIN_PIXELS = 655_360
41
+ GPT_IMAGE_2_MAX_PIXELS = 8_294_400
42
+ GPT_IMAGE_2_MAX_EDGE = 3840
43
+ GPT_IMAGE_2_MAX_RATIO = 3.0
44
+
45
+ MAX_IMAGE_BYTES = 50 * 1024 * 1024
46
+ MAX_BATCH_JOBS = 500
47
+
48
+
49
+ def _die(message: str, code: int = 1) -> None:
50
+ print(f"Error: {message}", file=sys.stderr)
51
+ raise SystemExit(code)
52
+
53
+
54
+ def _warn(message: str) -> None:
55
+ print(f"Warning: {message}", file=sys.stderr)
56
+
57
+
58
+ def _dependency_hint(package: str, *, upgrade: bool = False) -> str:
59
+ command = f"uv pip install {'-U ' if upgrade else ''}{package}"
60
+ return (
61
+ "Activate the repo-selected environment first, then install it with "
62
+ f"`{command}`. If this repo uses a local virtualenv, start with "
63
+ "`source .venv/bin/activate`; otherwise use this repo's configured shared fallback "
64
+ "environment. If your project declares dependencies, prefer that project's normal "
65
+ "`uv sync` flow."
66
+ )
67
+
68
+
69
+ def _ensure_api_key(dry_run: bool) -> None:
70
+ if os.getenv("OPENAI_API_KEY"):
71
+ print("OPENAI_API_KEY is set.", file=sys.stderr)
72
+ return
73
+ if dry_run:
74
+ _warn("OPENAI_API_KEY is not set; dry-run only.")
75
+ return
76
+ _die("OPENAI_API_KEY is not set. Export it before running.")
77
+
78
+
79
+ def _read_prompt(prompt: Optional[str], prompt_file: Optional[str]) -> str:
80
+ if prompt and prompt_file:
81
+ _die("Use --prompt or --prompt-file, not both.")
82
+ if prompt_file:
83
+ path = Path(prompt_file)
84
+ if not path.exists():
85
+ _die(f"Prompt file not found: {path}")
86
+ return path.read_text(encoding="utf-8").strip()
87
+ if prompt:
88
+ return prompt.strip()
89
+ _die("Missing prompt. Use --prompt or --prompt-file.")
90
+ return "" # unreachable
91
+
92
+
93
+ def _check_image_paths(paths: Iterable[str]) -> List[Path]:
94
+ resolved: List[Path] = []
95
+ for raw in paths:
96
+ path = Path(raw)
97
+ if not path.exists():
98
+ _die(f"Image file not found: {path}")
99
+ if path.stat().st_size > MAX_IMAGE_BYTES:
100
+ _warn(f"Image exceeds 50MB limit: {path}")
101
+ resolved.append(path)
102
+ return resolved
103
+
104
+
105
+ def _normalize_output_format(fmt: Optional[str]) -> str:
106
+ if not fmt:
107
+ return DEFAULT_OUTPUT_FORMAT
108
+ fmt = fmt.lower()
109
+ if fmt not in {"png", "jpeg", "jpg", "webp"}:
110
+ _die("output-format must be png, jpeg, jpg, or webp.")
111
+ return "jpeg" if fmt == "jpg" else fmt
112
+
113
+
114
+ def _parse_size(size: str) -> Optional[Tuple[int, int]]:
115
+ match = re.fullmatch(r"([1-9][0-9]*)x([1-9][0-9]*)", size)
116
+ if not match:
117
+ return None
118
+ return int(match.group(1)), int(match.group(2))
119
+
120
+
121
+ def _validate_gpt_image_2_size(size: str) -> None:
122
+ if size == "auto":
123
+ return
124
+
125
+ parsed = _parse_size(size)
126
+ if parsed is None:
127
+ _die("size must be auto or WIDTHxHEIGHT, for example 1024x1024.")
128
+
129
+ width, height = parsed
130
+ max_edge = max(width, height)
131
+ min_edge = min(width, height)
132
+ total_pixels = width * height
133
+
134
+ if max_edge > GPT_IMAGE_2_MAX_EDGE:
135
+ _die("gpt-image-2 size maximum edge length must be less than or equal to 3840px.")
136
+ if width % 16 != 0 or height % 16 != 0:
137
+ _die("gpt-image-2 size width and height must be multiples of 16px.")
138
+ if max_edge / min_edge > GPT_IMAGE_2_MAX_RATIO:
139
+ _die("gpt-image-2 size long edge to short edge ratio must not exceed 3:1.")
140
+ if total_pixels < GPT_IMAGE_2_MIN_PIXELS or total_pixels > GPT_IMAGE_2_MAX_PIXELS:
141
+ _die(
142
+ "gpt-image-2 size total pixels must be at least 655,360 and no more than 8,294,400."
143
+ )
144
+
145
+
146
+ def _validate_size(size: str, model: str) -> None:
147
+ if model == GPT_IMAGE_2_MODEL:
148
+ _validate_gpt_image_2_size(size)
149
+ return
150
+
151
+ if size not in ALLOWED_LEGACY_SIZES:
152
+ _die(
153
+ "size must be one of 1024x1024, 1536x1024, 1024x1536, or auto for this GPT Image model."
154
+ )
155
+
156
+
157
+ def _validate_quality(quality: str) -> None:
158
+ if quality not in ALLOWED_QUALITIES:
159
+ _die("quality must be one of low, medium, high, or auto.")
160
+
161
+
162
+ def _validate_background(background: Optional[str]) -> None:
163
+ if background not in ALLOWED_BACKGROUNDS:
164
+ _die("background must be one of transparent, opaque, or auto.")
165
+
166
+
167
+ def _validate_input_fidelity(input_fidelity: Optional[str]) -> None:
168
+ if input_fidelity not in ALLOWED_INPUT_FIDELITIES:
169
+ _die("input-fidelity must be one of low or high.")
170
+
171
+
172
+ def _validate_model(model: str) -> None:
173
+ if not model.startswith(GPT_IMAGE_MODEL_PREFIX):
174
+ _die(
175
+ "model must be a GPT Image model (for example gpt-image-1.5, gpt-image-1, or gpt-image-1-mini)."
176
+ )
177
+
178
+
179
+ def _validate_transparency(background: Optional[str], output_format: str) -> None:
180
+ if background == "transparent" and output_format not in {"png", "webp"}:
181
+ _die("transparent background requires output-format png or webp.")
182
+
183
+
184
+ def _validate_model_specific_options(
185
+ *,
186
+ model: str,
187
+ background: Optional[str],
188
+ input_fidelity: Optional[str] = None,
189
+ ) -> None:
190
+ if model != GPT_IMAGE_2_MODEL:
191
+ return
192
+ if background == "transparent":
193
+ _die(
194
+ "transparent backgrounds are not supported in gpt-image-2, the latest model. "
195
+ "Use --model gpt-image-1.5 --background transparent --output-format png instead."
196
+ )
197
+ if input_fidelity is not None:
198
+ _die(
199
+ "input_fidelity is not supported in gpt-image-2 because image inputs always use high fidelity for this model."
200
+ )
201
+
202
+
203
+ def _validate_generate_payload(payload: Dict[str, Any]) -> None:
204
+ model = str(payload.get("model", DEFAULT_MODEL))
205
+ _validate_model(model)
206
+ n = int(payload.get("n", 1))
207
+ if n < 1 or n > 10:
208
+ _die("n must be between 1 and 10")
209
+ size = str(payload.get("size", DEFAULT_SIZE))
210
+ quality = str(payload.get("quality", DEFAULT_QUALITY))
211
+ background = payload.get("background")
212
+ _validate_size(size, model)
213
+ _validate_quality(quality)
214
+ _validate_background(background)
215
+ _validate_model_specific_options(model=model, background=background)
216
+ oc = payload.get("output_compression")
217
+ if oc is not None and not (0 <= int(oc) <= 100):
218
+ _die("output_compression must be between 0 and 100")
219
+
220
+
221
+ def _build_output_paths(
222
+ out: str,
223
+ output_format: str,
224
+ count: int,
225
+ out_dir: Optional[str],
226
+ ) -> List[Path]:
227
+ ext = "." + output_format
228
+
229
+ if out_dir:
230
+ out_base = Path(out_dir)
231
+ out_base.mkdir(parents=True, exist_ok=True)
232
+ return [out_base / f"image_{i}{ext}" for i in range(1, count + 1)]
233
+
234
+ out_path = Path(out)
235
+ if out_path.exists() and out_path.is_dir():
236
+ out_path.mkdir(parents=True, exist_ok=True)
237
+ return [out_path / f"image_{i}{ext}" for i in range(1, count + 1)]
238
+
239
+ if out_path.suffix == "":
240
+ out_path = out_path.with_suffix(ext)
241
+ elif output_format and out_path.suffix.lstrip(".").lower() != output_format:
242
+ _warn(
243
+ f"Output extension {out_path.suffix} does not match output-format {output_format}."
244
+ )
245
+
246
+ if count == 1:
247
+ return [out_path]
248
+
249
+ return [
250
+ out_path.with_name(f"{out_path.stem}-{i}{out_path.suffix}")
251
+ for i in range(1, count + 1)
252
+ ]
253
+
254
+
255
+ def _augment_prompt(args: argparse.Namespace, prompt: str) -> str:
256
+ fields = _fields_from_args(args)
257
+ return _augment_prompt_fields(args.augment, prompt, fields)
258
+
259
+
260
+ def _augment_prompt_fields(augment: bool, prompt: str, fields: Dict[str, Optional[str]]) -> str:
261
+ if not augment:
262
+ return prompt
263
+
264
+ sections: List[str] = []
265
+ if fields.get("use_case"):
266
+ sections.append(f"Use case: {fields['use_case']}")
267
+ sections.append(f"Primary request: {prompt}")
268
+ if fields.get("scene"):
269
+ sections.append(f"Scene/background: {fields['scene']}")
270
+ if fields.get("subject"):
271
+ sections.append(f"Subject: {fields['subject']}")
272
+ if fields.get("style"):
273
+ sections.append(f"Style/medium: {fields['style']}")
274
+ if fields.get("composition"):
275
+ sections.append(f"Composition/framing: {fields['composition']}")
276
+ if fields.get("lighting"):
277
+ sections.append(f"Lighting/mood: {fields['lighting']}")
278
+ if fields.get("palette"):
279
+ sections.append(f"Color palette: {fields['palette']}")
280
+ if fields.get("materials"):
281
+ sections.append(f"Materials/textures: {fields['materials']}")
282
+ if fields.get("text"):
283
+ sections.append(f"Text (verbatim): \"{fields['text']}\"")
284
+ if fields.get("constraints"):
285
+ sections.append(f"Constraints: {fields['constraints']}")
286
+ if fields.get("negative"):
287
+ sections.append(f"Avoid: {fields['negative']}")
288
+
289
+ return "\n".join(sections)
290
+
291
+
292
+ def _fields_from_args(args: argparse.Namespace) -> Dict[str, Optional[str]]:
293
+ return {
294
+ "use_case": getattr(args, "use_case", None),
295
+ "scene": getattr(args, "scene", None),
296
+ "subject": getattr(args, "subject", None),
297
+ "style": getattr(args, "style", None),
298
+ "composition": getattr(args, "composition", None),
299
+ "lighting": getattr(args, "lighting", None),
300
+ "palette": getattr(args, "palette", None),
301
+ "materials": getattr(args, "materials", None),
302
+ "text": getattr(args, "text", None),
303
+ "constraints": getattr(args, "constraints", None),
304
+ "negative": getattr(args, "negative", None),
305
+ }
306
+
307
+
308
+ def _print_request(payload: dict) -> None:
309
+ print(json.dumps(payload, indent=2, sort_keys=True))
310
+
311
+
312
+ def _decode_and_write(images: List[str], outputs: List[Path], force: bool) -> None:
313
+ for idx, image_b64 in enumerate(images):
314
+ if idx >= len(outputs):
315
+ break
316
+ out_path = outputs[idx]
317
+ if out_path.exists() and not force:
318
+ _die(f"Output already exists: {out_path} (use --force to overwrite)")
319
+ out_path.parent.mkdir(parents=True, exist_ok=True)
320
+ out_path.write_bytes(base64.b64decode(image_b64))
321
+ print(f"Wrote {out_path}")
322
+
323
+
324
+ def _derive_downscale_path(path: Path, suffix: str) -> Path:
325
+ if suffix and not suffix.startswith("-") and not suffix.startswith("_"):
326
+ suffix = "-" + suffix
327
+ return path.with_name(f"{path.stem}{suffix}{path.suffix}")
328
+
329
+
330
+ def _downscale_image_bytes(image_bytes: bytes, *, max_dim: int, output_format: str) -> bytes:
331
+ try:
332
+ from PIL import Image
333
+ except Exception:
334
+ _die(f"Downscaling requires Pillow. {_dependency_hint('pillow')}")
335
+
336
+ if max_dim < 1:
337
+ _die("--downscale-max-dim must be >= 1")
338
+
339
+ with Image.open(BytesIO(image_bytes)) as img:
340
+ img.load()
341
+ w, h = img.size
342
+ scale = min(1.0, float(max_dim) / float(max(w, h)))
343
+ target = (max(1, int(round(w * scale))), max(1, int(round(h * scale))))
344
+
345
+ resized = img if target == (w, h) else img.resize(target, Image.Resampling.LANCZOS)
346
+
347
+ fmt = output_format.lower()
348
+ if fmt == "jpg":
349
+ fmt = "jpeg"
350
+
351
+ if fmt == "jpeg":
352
+ if resized.mode in ("RGBA", "LA") or ("transparency" in getattr(resized, "info", {})):
353
+ bg = Image.new("RGB", resized.size, (255, 255, 255))
354
+ bg.paste(resized.convert("RGBA"), mask=resized.convert("RGBA").split()[-1])
355
+ resized = bg
356
+ else:
357
+ resized = resized.convert("RGB")
358
+
359
+ out = BytesIO()
360
+ resized.save(out, format=fmt.upper())
361
+ return out.getvalue()
362
+
363
+
364
+ def _decode_write_and_downscale(
365
+ images: List[str],
366
+ outputs: List[Path],
367
+ *,
368
+ force: bool,
369
+ downscale_max_dim: Optional[int],
370
+ downscale_suffix: str,
371
+ output_format: str,
372
+ ) -> None:
373
+ for idx, image_b64 in enumerate(images):
374
+ if idx >= len(outputs):
375
+ break
376
+ out_path = outputs[idx]
377
+ if out_path.exists() and not force:
378
+ _die(f"Output already exists: {out_path} (use --force to overwrite)")
379
+ out_path.parent.mkdir(parents=True, exist_ok=True)
380
+
381
+ raw = base64.b64decode(image_b64)
382
+ out_path.write_bytes(raw)
383
+ print(f"Wrote {out_path}")
384
+
385
+ if downscale_max_dim is None:
386
+ continue
387
+
388
+ derived = _derive_downscale_path(out_path, downscale_suffix)
389
+ if derived.exists() and not force:
390
+ _die(f"Output already exists: {derived} (use --force to overwrite)")
391
+ derived.parent.mkdir(parents=True, exist_ok=True)
392
+ resized = _downscale_image_bytes(raw, max_dim=downscale_max_dim, output_format=output_format)
393
+ derived.write_bytes(resized)
394
+ print(f"Wrote {derived}")
395
+
396
+
397
+ def _create_client():
398
+ try:
399
+ from openai import OpenAI
400
+ except ImportError:
401
+ _die(f"openai SDK not installed in the active environment. {_dependency_hint('openai')}")
402
+ return OpenAI()
403
+
404
+
405
+ def _create_async_client():
406
+ try:
407
+ from openai import AsyncOpenAI
408
+ except ImportError:
409
+ try:
410
+ import openai as _openai # noqa: F401
411
+ except ImportError:
412
+ _die(
413
+ f"openai SDK not installed in the active environment. {_dependency_hint('openai')}"
414
+ )
415
+ _die(
416
+ "AsyncOpenAI not available in this openai SDK version. "
417
+ f"{_dependency_hint('openai', upgrade=True)}"
418
+ )
419
+ return AsyncOpenAI()
420
+
421
+
422
+ def _slugify(value: str) -> str:
423
+ value = value.strip().lower()
424
+ value = re.sub(r"[^a-z0-9]+", "-", value)
425
+ value = re.sub(r"-{2,}", "-", value).strip("-")
426
+ return value[:60] if value else "job"
427
+
428
+
429
+ def _normalize_job(job: Any, idx: int) -> Dict[str, Any]:
430
+ if isinstance(job, str):
431
+ prompt = job.strip()
432
+ if not prompt:
433
+ _die(f"Empty prompt at job {idx}")
434
+ return {"prompt": prompt}
435
+ if isinstance(job, dict):
436
+ if "prompt" not in job or not str(job["prompt"]).strip():
437
+ _die(f"Missing prompt for job {idx}")
438
+ return job
439
+ _die(f"Invalid job at index {idx}: expected string or object.")
440
+ return {} # unreachable
441
+
442
+
443
+ def _read_jobs_jsonl(path: str) -> List[Dict[str, Any]]:
444
+ p = Path(path)
445
+ if not p.exists():
446
+ _die(f"Input file not found: {p}")
447
+ jobs: List[Dict[str, Any]] = []
448
+ for line_no, raw in enumerate(p.read_text(encoding="utf-8").splitlines(), start=1):
449
+ line = raw.strip()
450
+ if not line or line.startswith("#"):
451
+ continue
452
+ try:
453
+ item: Any
454
+ if line.startswith("{"):
455
+ item = json.loads(line)
456
+ else:
457
+ item = line
458
+ jobs.append(_normalize_job(item, idx=line_no))
459
+ except json.JSONDecodeError as exc:
460
+ _die(f"Invalid JSON on line {line_no}: {exc}")
461
+ if not jobs:
462
+ _die("No jobs found in input file.")
463
+ if len(jobs) > MAX_BATCH_JOBS:
464
+ _die(f"Too many jobs ({len(jobs)}). Max is {MAX_BATCH_JOBS}.")
465
+ return jobs
466
+
467
+
468
+ def _merge_non_null(dst: Dict[str, Any], src: Dict[str, Any]) -> Dict[str, Any]:
469
+ merged = dict(dst)
470
+ for k, v in src.items():
471
+ if v is not None:
472
+ merged[k] = v
473
+ return merged
474
+
475
+
476
+ def _job_output_paths(
477
+ *,
478
+ out_dir: Path,
479
+ output_format: str,
480
+ idx: int,
481
+ prompt: str,
482
+ n: int,
483
+ explicit_out: Optional[str],
484
+ ) -> List[Path]:
485
+ out_dir.mkdir(parents=True, exist_ok=True)
486
+ ext = "." + output_format
487
+
488
+ if explicit_out:
489
+ base = Path(explicit_out)
490
+ if base.suffix == "":
491
+ base = base.with_suffix(ext)
492
+ elif base.suffix.lstrip(".").lower() != output_format:
493
+ _warn(
494
+ f"Job {idx}: output extension {base.suffix} does not match output-format {output_format}."
495
+ )
496
+ base = out_dir / base.name
497
+ else:
498
+ slug = _slugify(prompt[:80])
499
+ base = out_dir / f"{idx:03d}-{slug}{ext}"
500
+
501
+ if n == 1:
502
+ return [base]
503
+ return [
504
+ base.with_name(f"{base.stem}-{i}{base.suffix}")
505
+ for i in range(1, n + 1)
506
+ ]
507
+
508
+
509
+ def _extract_retry_after_seconds(exc: Exception) -> Optional[float]:
510
+ # Best-effort: openai SDK errors vary by version. Prefer a conservative fallback.
511
+ for attr in ("retry_after", "retry_after_seconds"):
512
+ val = getattr(exc, attr, None)
513
+ if isinstance(val, (int, float)) and val >= 0:
514
+ return float(val)
515
+ msg = str(exc)
516
+ m = re.search(r"retry[- ]after[:= ]+([0-9]+(?:\\.[0-9]+)?)", msg, re.IGNORECASE)
517
+ if m:
518
+ try:
519
+ return float(m.group(1))
520
+ except Exception:
521
+ return None
522
+ return None
523
+
524
+
525
+ def _is_rate_limit_error(exc: Exception) -> bool:
526
+ name = exc.__class__.__name__.lower()
527
+ if "ratelimit" in name or "rate_limit" in name:
528
+ return True
529
+ msg = str(exc).lower()
530
+ return "429" in msg or "rate limit" in msg or "too many requests" in msg
531
+
532
+
533
+ def _is_transient_error(exc: Exception) -> bool:
534
+ if _is_rate_limit_error(exc):
535
+ return True
536
+ name = exc.__class__.__name__.lower()
537
+ if "timeout" in name or "timedout" in name or "tempor" in name:
538
+ return True
539
+ msg = str(exc).lower()
540
+ return "timeout" in msg or "timed out" in msg or "connection reset" in msg
541
+
542
+
543
+ async def _generate_one_with_retries(
544
+ client: Any,
545
+ payload: Dict[str, Any],
546
+ *,
547
+ attempts: int,
548
+ job_label: str,
549
+ ) -> Any:
550
+ last_exc: Optional[Exception] = None
551
+ for attempt in range(1, attempts + 1):
552
+ try:
553
+ return await client.images.generate(**payload)
554
+ except Exception as exc:
555
+ last_exc = exc
556
+ if not _is_transient_error(exc):
557
+ raise
558
+ if attempt == attempts:
559
+ raise
560
+ sleep_s = _extract_retry_after_seconds(exc)
561
+ if sleep_s is None:
562
+ sleep_s = min(60.0, 2.0**attempt)
563
+ print(
564
+ f"{job_label} attempt {attempt}/{attempts} failed ({exc.__class__.__name__}); retrying in {sleep_s:.1f}s",
565
+ file=sys.stderr,
566
+ )
567
+ await asyncio.sleep(sleep_s)
568
+ raise last_exc or RuntimeError("unknown error")
569
+
570
+
571
+ async def _run_generate_batch(args: argparse.Namespace) -> int:
572
+ jobs = _read_jobs_jsonl(args.input)
573
+ out_dir = Path(args.out_dir)
574
+
575
+ base_fields = _fields_from_args(args)
576
+ base_payload = {
577
+ "model": args.model,
578
+ "n": args.n,
579
+ "size": args.size,
580
+ "quality": args.quality,
581
+ "background": args.background,
582
+ "output_format": args.output_format,
583
+ "output_compression": args.output_compression,
584
+ "moderation": args.moderation,
585
+ }
586
+
587
+ if args.dry_run:
588
+ for i, job in enumerate(jobs, start=1):
589
+ prompt = str(job["prompt"]).strip()
590
+ fields = _merge_non_null(base_fields, job.get("fields", {}))
591
+ # Allow flat job keys as well (use_case, scene, etc.)
592
+ fields = _merge_non_null(fields, {k: job.get(k) for k in base_fields.keys()})
593
+ augmented = _augment_prompt_fields(args.augment, prompt, fields)
594
+
595
+ job_payload = dict(base_payload)
596
+ job_payload["prompt"] = augmented
597
+ job_payload = _merge_non_null(job_payload, {k: job.get(k) for k in base_payload.keys()})
598
+ job_payload = {k: v for k, v in job_payload.items() if v is not None}
599
+
600
+ _validate_generate_payload(job_payload)
601
+ effective_output_format = _normalize_output_format(job_payload.get("output_format"))
602
+ _validate_transparency(job_payload.get("background"), effective_output_format)
603
+ job_payload["output_format"] = effective_output_format
604
+
605
+ n = int(job_payload.get("n", 1))
606
+ outputs = _job_output_paths(
607
+ out_dir=out_dir,
608
+ output_format=effective_output_format,
609
+ idx=i,
610
+ prompt=prompt,
611
+ n=n,
612
+ explicit_out=job.get("out"),
613
+ )
614
+ downscaled = None
615
+ if args.downscale_max_dim is not None:
616
+ downscaled = [
617
+ str(_derive_downscale_path(p, args.downscale_suffix)) for p in outputs
618
+ ]
619
+ _print_request(
620
+ {
621
+ "endpoint": "/v1/images/generations",
622
+ "job": i,
623
+ "outputs": [str(p) for p in outputs],
624
+ "outputs_downscaled": downscaled,
625
+ **job_payload,
626
+ }
627
+ )
628
+ return 0
629
+
630
+ client = _create_async_client()
631
+ sem = asyncio.Semaphore(args.concurrency)
632
+
633
+ any_failed = False
634
+
635
+ async def run_job(i: int, job: Dict[str, Any]) -> Tuple[int, Optional[str]]:
636
+ nonlocal any_failed
637
+ prompt = str(job["prompt"]).strip()
638
+ job_label = f"[job {i}/{len(jobs)}]"
639
+
640
+ fields = _merge_non_null(base_fields, job.get("fields", {}))
641
+ fields = _merge_non_null(fields, {k: job.get(k) for k in base_fields.keys()})
642
+ augmented = _augment_prompt_fields(args.augment, prompt, fields)
643
+
644
+ payload = dict(base_payload)
645
+ payload["prompt"] = augmented
646
+ payload = _merge_non_null(payload, {k: job.get(k) for k in base_payload.keys()})
647
+ payload = {k: v for k, v in payload.items() if v is not None}
648
+
649
+ n = int(payload.get("n", 1))
650
+ _validate_generate_payload(payload)
651
+ effective_output_format = _normalize_output_format(payload.get("output_format"))
652
+ _validate_transparency(payload.get("background"), effective_output_format)
653
+ payload["output_format"] = effective_output_format
654
+ outputs = _job_output_paths(
655
+ out_dir=out_dir,
656
+ output_format=effective_output_format,
657
+ idx=i,
658
+ prompt=prompt,
659
+ n=n,
660
+ explicit_out=job.get("out"),
661
+ )
662
+ try:
663
+ async with sem:
664
+ print(f"{job_label} starting", file=sys.stderr)
665
+ started = time.time()
666
+ result = await _generate_one_with_retries(
667
+ client,
668
+ payload,
669
+ attempts=args.max_attempts,
670
+ job_label=job_label,
671
+ )
672
+ elapsed = time.time() - started
673
+ print(f"{job_label} completed in {elapsed:.1f}s", file=sys.stderr)
674
+ images = [item.b64_json for item in result.data]
675
+ _decode_write_and_downscale(
676
+ images,
677
+ outputs,
678
+ force=args.force,
679
+ downscale_max_dim=args.downscale_max_dim,
680
+ downscale_suffix=args.downscale_suffix,
681
+ output_format=effective_output_format,
682
+ )
683
+ return i, None
684
+ except Exception as exc:
685
+ any_failed = True
686
+ print(f"{job_label} failed: {exc}", file=sys.stderr)
687
+ if args.fail_fast:
688
+ raise
689
+ return i, str(exc)
690
+
691
+ tasks = [asyncio.create_task(run_job(i, job)) for i, job in enumerate(jobs, start=1)]
692
+
693
+ try:
694
+ await asyncio.gather(*tasks)
695
+ except Exception:
696
+ for t in tasks:
697
+ if not t.done():
698
+ t.cancel()
699
+ raise
700
+
701
+ return 1 if any_failed else 0
702
+
703
+
704
+ def _generate_batch(args: argparse.Namespace) -> None:
705
+ exit_code = asyncio.run(_run_generate_batch(args))
706
+ if exit_code:
707
+ raise SystemExit(exit_code)
708
+
709
+
710
+ def _generate(args: argparse.Namespace) -> None:
711
+ prompt = _read_prompt(args.prompt, args.prompt_file)
712
+ prompt = _augment_prompt(args, prompt)
713
+
714
+ payload = {
715
+ "model": args.model,
716
+ "prompt": prompt,
717
+ "n": args.n,
718
+ "size": args.size,
719
+ "quality": args.quality,
720
+ "background": args.background,
721
+ "output_format": args.output_format,
722
+ "output_compression": args.output_compression,
723
+ "moderation": args.moderation,
724
+ }
725
+ payload = {k: v for k, v in payload.items() if v is not None}
726
+
727
+ output_format = _normalize_output_format(args.output_format)
728
+ _validate_transparency(args.background, output_format)
729
+ payload["output_format"] = output_format
730
+ output_paths = _build_output_paths(args.out, output_format, args.n, args.out_dir)
731
+ downscaled = None
732
+ if args.downscale_max_dim is not None:
733
+ downscaled = [str(_derive_downscale_path(p, args.downscale_suffix)) for p in output_paths]
734
+
735
+ if args.dry_run:
736
+ _print_request(
737
+ {
738
+ "endpoint": "/v1/images/generations",
739
+ "outputs": [str(p) for p in output_paths],
740
+ "outputs_downscaled": downscaled,
741
+ **payload,
742
+ }
743
+ )
744
+ return
745
+
746
+ print(
747
+ "Calling Image API (generation). This can take up to a couple of minutes.",
748
+ file=sys.stderr,
749
+ )
750
+ started = time.time()
751
+ client = _create_client()
752
+ result = client.images.generate(**payload)
753
+ elapsed = time.time() - started
754
+ print(f"Generation completed in {elapsed:.1f}s.", file=sys.stderr)
755
+
756
+ images = [item.b64_json for item in result.data]
757
+ _decode_write_and_downscale(
758
+ images,
759
+ output_paths,
760
+ force=args.force,
761
+ downscale_max_dim=args.downscale_max_dim,
762
+ downscale_suffix=args.downscale_suffix,
763
+ output_format=output_format,
764
+ )
765
+
766
+
767
+ def _edit(args: argparse.Namespace) -> None:
768
+ prompt = _read_prompt(args.prompt, args.prompt_file)
769
+ prompt = _augment_prompt(args, prompt)
770
+
771
+ image_paths = _check_image_paths(args.image)
772
+ mask_path = Path(args.mask) if args.mask else None
773
+ if mask_path:
774
+ if not mask_path.exists():
775
+ _die(f"Mask file not found: {mask_path}")
776
+ if mask_path.suffix.lower() != ".png":
777
+ _warn(f"Mask should be a PNG with an alpha channel: {mask_path}")
778
+ if mask_path.stat().st_size > MAX_IMAGE_BYTES:
779
+ _warn(f"Mask exceeds 50MB limit: {mask_path}")
780
+
781
+ payload = {
782
+ "model": args.model,
783
+ "prompt": prompt,
784
+ "n": args.n,
785
+ "size": args.size,
786
+ "quality": args.quality,
787
+ "background": args.background,
788
+ "output_format": args.output_format,
789
+ "output_compression": args.output_compression,
790
+ "input_fidelity": args.input_fidelity,
791
+ "moderation": args.moderation,
792
+ }
793
+ payload = {k: v for k, v in payload.items() if v is not None}
794
+
795
+ output_format = _normalize_output_format(args.output_format)
796
+ _validate_transparency(args.background, output_format)
797
+ payload["output_format"] = output_format
798
+ _validate_input_fidelity(args.input_fidelity)
799
+ output_paths = _build_output_paths(args.out, output_format, args.n, args.out_dir)
800
+ downscaled = None
801
+ if args.downscale_max_dim is not None:
802
+ downscaled = [str(_derive_downscale_path(p, args.downscale_suffix)) for p in output_paths]
803
+
804
+ if args.dry_run:
805
+ payload_preview = dict(payload)
806
+ payload_preview["image"] = [str(p) for p in image_paths]
807
+ if mask_path:
808
+ payload_preview["mask"] = str(mask_path)
809
+ _print_request(
810
+ {
811
+ "endpoint": "/v1/images/edits",
812
+ "outputs": [str(p) for p in output_paths],
813
+ "outputs_downscaled": downscaled,
814
+ **payload_preview,
815
+ }
816
+ )
817
+ return
818
+
819
+ print(
820
+ f"Calling Image API (edit) with {len(image_paths)} image(s).",
821
+ file=sys.stderr,
822
+ )
823
+ started = time.time()
824
+ client = _create_client()
825
+
826
+ with _open_files(image_paths) as image_files, _open_mask(mask_path) as mask_file:
827
+ request = dict(payload)
828
+ request["image"] = image_files if len(image_files) > 1 else image_files[0]
829
+ if mask_file is not None:
830
+ request["mask"] = mask_file
831
+ result = client.images.edit(**request)
832
+
833
+ elapsed = time.time() - started
834
+ print(f"Edit completed in {elapsed:.1f}s.", file=sys.stderr)
835
+ images = [item.b64_json for item in result.data]
836
+ _decode_write_and_downscale(
837
+ images,
838
+ output_paths,
839
+ force=args.force,
840
+ downscale_max_dim=args.downscale_max_dim,
841
+ downscale_suffix=args.downscale_suffix,
842
+ output_format=output_format,
843
+ )
844
+
845
+
846
+ def _open_files(paths: List[Path]):
847
+ return _FileBundle(paths)
848
+
849
+
850
+ def _open_mask(mask_path: Optional[Path]):
851
+ if mask_path is None:
852
+ return _NullContext()
853
+ return _SingleFile(mask_path)
854
+
855
+
856
+ class _NullContext:
857
+ def __enter__(self):
858
+ return None
859
+
860
+ def __exit__(self, exc_type, exc, tb):
861
+ return False
862
+
863
+
864
+ class _SingleFile:
865
+ def __init__(self, path: Path):
866
+ self._path = path
867
+ self._handle = None
868
+
869
+ def __enter__(self):
870
+ self._handle = self._path.open("rb")
871
+ return self._handle
872
+
873
+ def __exit__(self, exc_type, exc, tb):
874
+ if self._handle:
875
+ try:
876
+ self._handle.close()
877
+ except Exception:
878
+ pass
879
+ return False
880
+
881
+
882
+ class _FileBundle:
883
+ def __init__(self, paths: List[Path]):
884
+ self._paths = paths
885
+ self._handles: List[object] = []
886
+
887
+ def __enter__(self):
888
+ self._handles = [p.open("rb") for p in self._paths]
889
+ return self._handles
890
+
891
+ def __exit__(self, exc_type, exc, tb):
892
+ for handle in self._handles:
893
+ try:
894
+ handle.close()
895
+ except Exception:
896
+ pass
897
+ return False
898
+
899
+
900
+ def _add_shared_args(parser: argparse.ArgumentParser) -> None:
901
+ parser.add_argument("--model", default=DEFAULT_MODEL)
902
+ parser.add_argument("--prompt")
903
+ parser.add_argument("--prompt-file")
904
+ parser.add_argument("--n", type=int, default=1)
905
+ parser.add_argument("--size", default=DEFAULT_SIZE)
906
+ parser.add_argument("--quality", default=DEFAULT_QUALITY)
907
+ parser.add_argument("--background")
908
+ parser.add_argument("--output-format")
909
+ parser.add_argument("--output-compression", type=int)
910
+ parser.add_argument("--moderation")
911
+ parser.add_argument("--out", default=DEFAULT_OUTPUT_PATH)
912
+ parser.add_argument("--out-dir")
913
+ parser.add_argument("--force", action="store_true")
914
+ parser.add_argument("--dry-run", action="store_true")
915
+ parser.add_argument("--augment", dest="augment", action="store_true")
916
+ parser.add_argument("--no-augment", dest="augment", action="store_false")
917
+ parser.set_defaults(augment=True)
918
+
919
+ # Prompt augmentation hints
920
+ parser.add_argument("--use-case")
921
+ parser.add_argument("--scene")
922
+ parser.add_argument("--subject")
923
+ parser.add_argument("--style")
924
+ parser.add_argument("--composition")
925
+ parser.add_argument("--lighting")
926
+ parser.add_argument("--palette")
927
+ parser.add_argument("--materials")
928
+ parser.add_argument("--text")
929
+ parser.add_argument("--constraints")
930
+ parser.add_argument("--negative")
931
+
932
+ # Post-processing (optional): generate an additional downscaled copy for fast web loading.
933
+ parser.add_argument("--downscale-max-dim", type=int)
934
+ parser.add_argument("--downscale-suffix", default=DEFAULT_DOWNSCALE_SUFFIX)
935
+
936
+
937
+ def main() -> int:
938
+ parser = argparse.ArgumentParser(
939
+ description="Fallback CLI for explicit image generation or editing via GPT Image models"
940
+ )
941
+ subparsers = parser.add_subparsers(dest="command", required=True)
942
+
943
+ gen_parser = subparsers.add_parser("generate", help="Create a new image")
944
+ _add_shared_args(gen_parser)
945
+ gen_parser.set_defaults(func=_generate)
946
+
947
+ batch_parser = subparsers.add_parser(
948
+ "generate-batch",
949
+ help="Generate multiple prompts concurrently (JSONL input)",
950
+ )
951
+ _add_shared_args(batch_parser)
952
+ batch_parser.add_argument("--input", required=True, help="Path to JSONL file (one job per line)")
953
+ batch_parser.add_argument("--concurrency", type=int, default=DEFAULT_CONCURRENCY)
954
+ batch_parser.add_argument("--max-attempts", type=int, default=3)
955
+ batch_parser.add_argument("--fail-fast", action="store_true")
956
+ batch_parser.set_defaults(func=_generate_batch)
957
+
958
+ edit_parser = subparsers.add_parser("edit", help="Edit an existing image")
959
+ _add_shared_args(edit_parser)
960
+ edit_parser.add_argument("--image", action="append", required=True)
961
+ edit_parser.add_argument("--mask")
962
+ edit_parser.add_argument("--input-fidelity")
963
+ edit_parser.set_defaults(func=_edit)
964
+
965
+ args = parser.parse_args()
966
+ if args.n < 1 or args.n > 10:
967
+ _die("--n must be between 1 and 10")
968
+ if getattr(args, "concurrency", 1) < 1 or getattr(args, "concurrency", 1) > 25:
969
+ _die("--concurrency must be between 1 and 25")
970
+ if getattr(args, "max_attempts", 3) < 1 or getattr(args, "max_attempts", 3) > 10:
971
+ _die("--max-attempts must be between 1 and 10")
972
+ if args.output_compression is not None and not (0 <= args.output_compression <= 100):
973
+ _die("--output-compression must be between 0 and 100")
974
+ if args.command == "generate-batch" and not args.out_dir:
975
+ _die("generate-batch requires --out-dir")
976
+ if getattr(args, "downscale_max_dim", None) is not None and args.downscale_max_dim < 1:
977
+ _die("--downscale-max-dim must be >= 1")
978
+
979
+ _validate_model(args.model)
980
+ _validate_size(args.size, args.model)
981
+ _validate_quality(args.quality)
982
+ _validate_background(args.background)
983
+ _validate_model_specific_options(
984
+ model=args.model,
985
+ background=args.background,
986
+ input_fidelity=getattr(args, "input_fidelity", None),
987
+ )
988
+ _ensure_api_key(args.dry_run)
989
+
990
+ args.func(args)
991
+ return 0
992
+
993
+
994
+ if __name__ == "__main__":
995
+ raise SystemExit(main())