
Gpt Image 2
- 173 installs
- 339 repo stars
- Updated August 4, 2026
- glebis/claude-skills
Generate, edit, and iterate marketing visuals, UI mock assets, and illustrative graphics via GPT image models inside Claude Code build workflows.
About
gpt-image-2 wires GPT-class image generation into Claude Code so teams can create and refine visuals—mockups, icons, hero art, and social assets—during development without external design tools.
- GPT image model integration
- Prompted asset generation
- UI and marketing visuals
- In-repo image iteration
- Agent-friendly media workflow
Gpt Image 2 by the numbers
- 173 all-time installs (skills.sh)
- Ranked #655 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/glebis/claude-skills --skill gpt-image-2Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 173 |
|---|---|
| repo stars | ★ 339 |
| Last updated | August 4, 2026 |
| Repository | glebis/claude-skills ↗ |
What it does
Generate, edit, and iterate marketing visuals, UI mock assets, and illustrative graphics via GPT image models inside Claude Code build workflows.
Files
GPT Image 2 — Interactive Image Generation
Generate and edit images via OpenAI's GPT Image 2 API with an interactive, guided workflow.
Interactive Flow
When the user invokes this skill, guide them through these steps using AskUserQuestion. Do not skip steps — the interactive flow is the core experience.
Step 1: What are we making?
Ask the user what they want to create. Offer these options:
- Single image — one image from a text prompt
- Photo edit — transform an existing photo into a style
- Carousel — 5-10 cohesive slides for LinkedIn/Instagram
- Variants — multiple versions of the same concept
- Quick generate — skip questions, just run the prompt
If the user already provided a clear prompt (e.g. "generate an editorial image of a rocket"), skip to Step 3.
Step 2: Style selection
Show the user available presets grouped by category. Read presets.yaml and present them:
Visual styles (no text in image): editorial, blueprint, ink, risograph, wireframe, constellation, brutalist, grain
Text-heavy (leverages GPT Image 2 text rendering): infographic, slide, diagram, poster, menu, manga
Community favorites: trading-card, pixar, app-mockup, isometric, action-figure, cinematic, panorama
Reference-anchored: vhs — 1980s late-night infomercial title card: scanline-striped gradient italic caps on pure black. It auto-attaches a bundled reference image (references/vhs-infomercial.png), so the look stays consistent batch-to-batch. Pass the ad copy as the subject; for multi-line copy separate lines with / (e.g. --preset vhs "THEY TRUSTED YOU / NOW / PROVE IT").
Custom — user describes their own style
Ask: "Which style? Or describe your own."
Step 3: Platform & sizing
Ask where this will be used:
- YouTube thumbnail (1280×720)
- Instagram square (1080×1080)
- Slides/presentation (1920×1080)
- Blog hero (1200×630)
- X/Twitter (1600×900)
- Story (1080×1920)
- Custom size
- No resize (use API default)
Step 3.5: Preflight prompt check (automatic)
Before any generation spend, the script now composes the final prompt first (preset + subject + style), then checks it for internal contradictions — most often a preset that hard-codes something the subject overrides (e.g. the editorial preset forces "on pure black background" while your subject asks for a warm off-white ground).
The check prefers a fast Haiku call via the llm CLI; if Haiku is unavailable (no llm, no Anthropic credit) it falls back to the configured llm default model, then to a built-in static heuristic. The resolved prompt and the verdict are printed. If a conflict is found, generation is aborted before spending — fix the prompt or preset and re-run, or override with --force (generate anyway) or --no-preflight (skip the check). This is what prevents the "generated on the wrong background, now regenerate" waste.
When composing prompts that set a background/palette, don't combine a background-fixing preset (`editorial`, `blueprint`, etc.) with a different requested background — either drop the preset and specify the full style yourself, or accept the preset's background.
Step 4: Draft first, then final
Always generate a draft first unless the user says "skip draft" or uses --draft false.
1. Generate with --draft (quality=low, ~$0.006/image) 2. Show the image to the user using the Read tool 3. Ask: "Like this direction? I can: (a) generate final quality, (b) adjust the prompt, (c) try a different style, (d) regenerate with a new seed" 4. If approved, generate final with --quality high (~$0.21/image) 5. Use --seed from the draft to maintain composition when upgrading to final
This draft→final flow saves ~97% on iteration costs.
Step 5: Show result and offer next actions
After generation, always: 1. Show the image using the Read tool 2. Open it with open <path> for full-resolution preview 3. Report the cost 4. Offer: "Want to (a) generate variants, (b) edit this further, (c) use as reference for more images, (d) done?"
Carousel Workflow
When the user wants a carousel (5-10 slides):
1. Story arc
Ask: "What's the story? Give me the key message and I'll draft a 10-slide arc."
Then propose a slide-by-slide plan like:
Slide 1: [Cover] — hook headline + hero image
Slide 2: [Problem] — bold statement
Slide 3: [Context] — illustration + explanation
...
Slide 10: [CTA] — call to action with URLAsk the user to approve or modify the plan.
2. Style consistency
Use the same preset + seed range across all slides. For carousels:
- Pick one visual style for all slides
- Use
--seedto lock composition patterns - Include pagination dots in prompts (e.g., "10 small dots at bottom, third dot highlighted orange")
- Maintain consistent color palette and typography
3. Draft batch
Generate all slides as drafts first ($0.006 × 10 = $0.06 total). Show them all to the user as a contact sheet or one by one. Ask which ones to regenerate or adjust.
4. Final batch
Only generate finals for approved slides. Offer to generate all at once with -y flag.
Photo Edit Workflow
When the user wants to transform a photo:
1. Ask for the source image (file path or clipboard) 2. For clipboard: save with osascript to a temp file 3. Show available styles and ask which to try 4. Generate a draft edit first 5. Show result, ask if they want adjustments 6. Generate final when approved
Use --edit <path> for the API call.
Cost Awareness
Always communicate costs before generating:
| Quality | Per image | 10-slide carousel |
|---|---|---|
--draft (low) | $0.006 | $0.06 |
| medium | $0.05 | $0.50 |
| high (default) | $0.21 | $2.10 |
| high + thinking | $0.25-0.42 | $2.50-4.20 |
Thinking mode adds 20-100% cost. Only suggest it for text-heavy or complex compositions.
The script auto-confirms when cost < $0.50. Above that, it prompts the user.
Prompt Engineering Tips
When helping users write prompts, apply these patterns:
1. Structure: Scene → Subject → Detail → Lighting → Constraint 2. Front-load the subject: put the main thing first 3. For text in images: quote exact text with single quotes: 'with the headline "Hello World"' 4. Character consistency: maintain a 5-tuple: age + appearance + hairstyle + distinctive features + clothing 5. Style tags at end: append tags like editorial-magazine, studio-product to converge batches 6. Use `--seed` for iteration: lock composition, vary only the prompt details
CLI Reference
# Basic generation
scripts/gpt_image_2.py "prompt" output.png
# With preset and platform
scripts/gpt_image_2.py --preset editorial --platform square "subject" out.png
# Draft mode (~$0.006/image)
scripts/gpt_image_2.py --draft "prompt" out.png
# With thinking for complex layouts
scripts/gpt_image_2.py --thinking medium --preset diagram "OAuth flow" out.png
# Seed for reproducibility
scripts/gpt_image_2.py --seed 42 "prompt" out.png
# Edit existing photo
scripts/gpt_image_2.py --edit photo.png "transform into constellation style" out.png
# Reference-anchored preset (auto-attaches its bundled reference image)
scripts/gpt_image_2.py --preset vhs --platform youtube "THEY TRUSTED YOU / NOW / PROVE IT" ad.png
# Variants with contact sheet
scripts/gpt_image_2.py --n 4 --preset ink "mountain" out.png
# Cost estimate
scripts/gpt_image_2.py --estimate --n 10 --quality high "batch test"
# Skip confirmation
scripts/gpt_image_2.py -y --n 10 "batch" out.png
# Dry run (show prompt without API call)
scripts/gpt_image_2.py --dry-run --preset editorial "test" out.png
# Preflight runs automatically before spend; override if needed
scripts/gpt_image_2.py --force "prompt with a known conflict" out.png # generate anyway
scripts/gpt_image_2.py --no-preflight "prompt" out.png # skip the checkFiles
scripts/gpt_image_2.py— main CLI (Python, requires PyYAML)presets.yaml— style presets (visual + text-heavy + community + reference-anchored). A preset may declare areference:path (relative to the skill dir); it auto-attaches as a style anchor unless the user passes their own--reference. See thevhspreset.platforms.yaml— 8 platform sizing presetsreferences/api_reference.md— full API documentationreferences/vhs-infomercial.png— bundled style anchor for thevhspreset~/.config/gpt-image-2/config.yaml— user defaults~/.config/gpt-image-2/history.jsonl— generation log~/.config/gpt-image-2/last.json— last run (foragain)
{
"name": "gpt-image-2",
"description": "Generate and edit images using OpenAI's GPT Image 2 API. Supports style presets (including text-heavy ones like infograp",
"author": {
"name": "Gleb Kalinin"
},
"repository": "https://github.com/glebis/claude-skills",
"license": "MIT"
}# Platform-specific output presets
# Each platform sets target dimensions; image is resized/cropped to fit.
# Usage: gpt_image_2.py --platform youtube "subject" out.png
youtube:
description: "YouTube thumbnail"
width: 1280
height: 720
youtube-short:
description: "YouTube Shorts cover (vertical)"
width: 1080
height: 1920
slides:
description: "Presentation slide (16:9 HD)"
width: 1920
height: 1080
blog:
description: "Blog hero / social preview (1.91:1)"
width: 1200
height: 630
x:
description: "X / Twitter in-stream image"
width: 1600
height: 900
square:
description: "Instagram / LinkedIn square post"
width: 1080
height: 1080
story:
description: "Instagram / TikTok story (9:16)"
width: 1080
height: 1920
pinterest:
description: "Pinterest pin (2:3)"
width: 1000
height: 1500
# GPT Image 2 Style Presets
# Usage: gpt_image_2.py --preset <name> "subject description" output.png
#
# Each preset defines a style wrapper around the user's subject.
# The subject gets interpolated into the prompt template at {subject}.
#
# Visual presets include "no text" to avoid unwanted labels.
# Text-heavy presets explicitly request readable typography.
# --- Visual Presets (aesthetic-focused) ---
line-art:
description: "Monochrome line art — clean black lines on white, no fills, no shading, technical illustration feel"
prompt: "{subject}, minimalist monochrome line art, clean black lines on pure white background, no fills, no gradients, no shading, no color, technical illustration style, simple and elegant, even line weight, generous whitespace, no text, no labels"
editorial:
description: "Dark editorial style — thin lines on black, muted palette, technical feel"
prompt: "on pure black background, {subject}, thin pale gray and subtle orange line art, minimalist technical diagram aesthetic, clean vector style, sparse with lots of negative space, dark moody, no text, no labels"
blueprint:
description: "Technical blueprint — white/cyan lines on dark navy, engineering drawing feel"
prompt: "technical blueprint drawing of {subject}, thin white and light cyan lines on dark navy #0a1628 background, engineering schematic style, precise geometric, grid dots in background, architectural, no text, clean"
ink:
description: "Japanese ink wash — organic brushstrokes, minimal, high contrast"
prompt: "{subject}, Japanese ink wash sumi-e style, black ink on off-white rice paper, minimal brushstrokes, negative space, organic flowing lines, zen aesthetic, monochrome, no text"
risograph:
description: "Risograph print — flat colors, grain, slight misregistration, zine aesthetic"
prompt: "{subject}, risograph print style, flat color fills with visible halftone grain, terracotta orange and sage green ink on cream paper, slight color misregistration, screen print texture, vintage zine aesthetic, no text"
wireframe:
description: "3D wireframe — glowing edges on black, holographic feel"
prompt: "{subject}, 3D wireframe mesh rendering on pure black background, thin glowing white and orange edge lines, transparent polygonal structure, holographic feel, no solid fills, technical visualization, no text"
constellation:
description: "Star map — dots connected by faint lines, celestial feel"
prompt: "{subject} depicted as a constellation pattern, small white dots connected by very faint thin lines on deep black sky background, scattered star dust, celestial map aesthetic, minimal, ethereal, no text"
brutalist:
description: "Neobrutalist — bold shapes, black borders, hard shadows, flat saturated colors"
prompt: "{subject}, neobrutalist graphic design style, bold geometric shapes, thick 3px black outlines, hard offset shadows, flat saturated colors orange and yellow on white, no gradients, no rounded corners, poster art, no text"
grain:
description: "Film grain photo — moody, high ISO, warm tones, cinematic"
prompt: "photograph of {subject}, heavy film grain ISO 3200, warm color grading, shallow depth of field, cinematic lighting, moody atmosphere, 35mm analog film look, slight vignette"
# --- Text-Heavy Presets (leverage GPT Image 2 text rendering) ---
infographic:
description: "Data-rich visual explainer with labels, clean typography, structured layout"
prompt: "clean infographic about {subject}, structured layout with clear sections, readable sans-serif labels and headings, data visualization elements, white or light background, professional color palette, clear visual hierarchy, information design"
thinking: medium
slide:
description: "Presentation slide — title + subtitle, minimal background, ready for keynote"
prompt: "presentation slide about {subject}, large bold title text, smaller subtitle, clean minimal background with subtle gradient, professional typography, wide 16:9 composition, ample whitespace, corporate-ready design"
thinking: low
diagram:
description: "Labeled technical diagram — boxes, arrows, clean lines, readable annotations"
prompt: "technical diagram of {subject}, labeled boxes connected by arrows, clean lines, readable sans-serif annotations, structured flowchart or architecture layout, white background, professional, engineering documentation style"
thinking: medium
poster:
description: "Event poster — bold headline + details, striking typography"
prompt: "event poster for {subject}, bold large headline typography, event details in smaller text below, striking visual design, strong color contrast, professional print-ready layout, eye-catching composition"
thinking: low
menu:
description: "Print-ready menu/card — elegant type pairing, structured items"
prompt: "elegant menu or card layout for {subject}, refined serif and sans-serif type pairing, structured list of items with descriptions, cream or white background, subtle decorative elements, print-ready, restaurant or event quality"
thinking: low
manga:
description: "Comic panel layout — speech bubbles, SFX text, dynamic composition"
prompt: "{subject}, manga comic panel layout, black and white with screentone shading, speech bubbles with readable text, dynamic action lines, Japanese manga aesthetic, expressive characters, sound effect text"
thinking: medium
# --- Community Favorites (popular GPT Image 2 templates) ---
trading-card:
description: "Retro holographic trading card with stats, borders, and character portrait"
prompt: "{subject}, retro holographic trading card design, ornate gold border with embossed patterns, character portrait in center, stat icons at bottom, holographic rainbow shimmer effect, dark background, collectible card game aesthetic"
thinking: medium
pixar:
description: "Pixar/Disney 3D character with subsurface scattering and big eyes"
prompt: "{subject}, Pixar 3D animated character style, subsurface scattering skin, big expressive eyes, soft studio lighting, 3/4 view, clean colorful background, friendly appealing design, high detail render"
thinking: low
app-mockup:
description: "Mobile app UI mockup in iPhone frame with status bar"
prompt: "iPhone mockup showing a mobile app screen for {subject}, modern UI design, clean interface with navigation bar, status bar at top showing time and battery, rounded corners, shadow beneath phone, white or gradient background, professional product shot"
thinking: medium
isometric:
description: "Isometric 3D miniature diorama with PBR materials"
prompt: "{subject}, isometric 3D miniature diorama, 45 degree top-down view, PBR materials, tiny detailed scene, soft shadows, clean white background, game asset style, vibrant colors, tilt-shift effect"
thinking: low
action-figure:
description: "Action figure in sealed blister packaging with branding"
prompt: "{subject}, action figure toy in sealed clear blister packaging, product photography, cardboard backing with logo and branding, plastic bubble, retail shelf display, studio lighting, white background"
thinking: medium
cinematic:
description: "Anamorphic widescreen film still with lens flares"
prompt: "{subject}, anamorphic widescreen 2.39:1 aspect ratio, cinematic film still, lens flare, shallow depth of field, moody color grading, film grain, dramatic lighting, movie poster quality"
panorama:
description: "360° equirectangular panorama for VR/immersive view"
prompt: "{subject}, 360 degree equirectangular panoramic view, seamless edges, immersive environment, photorealistic, HDR lighting, complete surrounding scene, VR ready"
thinking: medium
nordic:
description: "Calm Scandinavian — muted tones, fog, clean geometry, quiet atmosphere"
prompt: "{subject}, Scandinavian design aesthetic, muted cool palette of slate blue and warm gray and soft white, foggy atmosphere, clean minimal geometry, natural textures like birch wood and raw linen, quiet contemplative mood, diffused northern light, no harsh shadows, serene and understated, hygge feeling"
bauhaus:
description: "Bauhaus — primary colors, geometric shapes, rough grain texture, Kandinsky meets Moholy-Nagy"
prompt: "{subject}, Bauhaus design style, primary colors red yellow blue on off-white textured paper, bold geometric circles triangles rectangles, rough grain texture visible throughout, asymmetric grid composition, sans-serif typography, constructivist influence, handmade printing feel, no gradients, flat shapes with visible paper texture"
flat-social:
description: "LinkedIn/social card — flat design, bold text with hard drop shadow, high contrast"
prompt: "{subject}, flat graphic design for social media, bold large sans-serif text with hard black drop shadow offset 4px, vibrant solid color background, no gradients, no 3D, extremely clean and minimal, high contrast, Instagram-ready square composition, text dominates the frame, modern editorial feel"
linkedin-hero:
description: "LinkedIn hero card — professional, large quote text, author photo area, brand bar"
prompt: "{subject}, professional LinkedIn post card design, large bold serif quote text centered with hard flat shadow, muted professional background color like navy or charcoal, thin accent color bar at bottom, clean whitespace, author name area at bottom left, modern thought leadership aesthetic, square format"
social-bold:
description: "Social media statement — giant text fills frame, textured flat background, punk energy"
prompt: "{subject}, social media graphic with giant bold condensed sans-serif text filling entire frame, rough textured flat background in a single saturated color, text in white or black with hard offset shadow, punk poster energy, no images just typography, raw and direct, square format"
social-split:
description: "Split card — image left half, bold text right half, flat color block"
prompt: "{subject}, split composition social media card, left half is a photograph or illustration, right half is a solid flat color block with large bold white text, clean dividing line between halves, modern editorial magazine layout, no gradients, high contrast, square format"
# --- Reference-anchored preset ---
# `vhs` auto-attaches references/vhs-infomercial.png as a style anchor.
# Pass the ad copy as the subject; for multi-line copy, separate lines with " / ".
vhs:
description: "1980s late-night infomercial VHS title card — scanline-striped gradient italic caps on pure black. Auto-uses bundled reference."
prompt: "1980s late-night infomercial VHS title card. Pure black background, total negative space. Bold heavy italic condensed sans-serif capitals, centered, stacked on multiple lines. The letters are filled with smooth retro gradients (hot orange-to-yellow on the top line, electric teal-to-cyan and lime-green on the lower lines) and cut across by crisp horizontal CRT scanline striping that breaks the type into thin bars. Subtle chromatic aberration, soft phosphor glow, faint VHS noise. No background imagery, no logos, no people — just glowing gradient infomercial text on black. The exact text reads, on centered stacked lines: '{subject}'. Spell every word correctly."
reference: references/vhs-infomercial.png
thinking: medium
GPT Image 2 API Reference
Endpoint
Generation: POST https://api.openai.com/v1/images/generations Editing: POST https://api.openai.com/v1/images/edits
Authentication
Authorization: Bearer <OPENAI_API_KEY>
Content-Type: application/jsonGeneration Request Body
{
"model": "gpt-image-2",
"prompt": "a cat wearing a space suit",
"n": 1,
"size": "1024x1024",
"quality": "high",
"thinking": "off",
"response_format": "b64_json"
}Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
model | string | required | gpt-image-2 |
prompt | string | required | Text description of desired image |
n | integer | 1 | Number of images to generate (1-10) |
size | string | 1024x1024 | Image size. Options: 1024x1024, 1536x1024, 1024x1536, 2000x1024, 1024x2000, auto |
quality | string | high | low, medium, high |
thinking | string | off | off, low, medium, high — reasoning before rendering |
response_format | string | url | url or b64_json |
Edit Request Body
{
"model": "gpt-image-2",
"image": "<base64-encoded-image>",
"prompt": "make the background blue",
"size": "1024x1024",
"quality": "high",
"response_format": "b64_json"
}Response
{
"created": 1714000000,
"data": [
{
"b64_json": "<base64-encoded-png>",
"revised_prompt": "..."
}
],
"usage": {
"input_tokens": 150,
"output_tokens": 4096,
"input_tokens_details": {
"text_tokens": 50,
"image_tokens": 100
},
"output_tokens_details": {
"text_tokens": 0,
"image_tokens": 4096
}
}
}Pricing (per million tokens)
| Token Type | Cost |
|---|---|
| Input text | $5 |
| Output text (thinking) | $10 |
| Input image | $8 |
| Output image | $30 |
Approximate per-image costs
| Thinking | ~1024x1024 High |
|---|---|
| off | ~$0.19 |
| low | ~$0.23 |
| medium | ~$0.29 |
| high | ~$0.38 |
OpenRouter
Same request format, different base URL:
- Generation:
POST https://openrouter.ai/api/v1/images/generations - Editing:
POST https://openrouter.ai/api/v1/images/edits - Extra headers:
HTTP-Referer,X-Title
Error Codes
| Code | Meaning | Retry? |
|---|---|---|
| 400 | Bad request / invalid params | No |
| 401 | Invalid API key | No |
| 403 | Safety filter / content policy | No |
| 429 | Rate limit exceeded | Yes (backoff) |
| 500 | Server error | Yes (backoff) |
| 502 | Bad gateway | Yes (backoff) |
{
"timestamp": "2026-04-23T17:11:10.865859",
"prompt": "Pure black background. Center: a stunning editorial illustration of the GPT Image 2 logo concept \u2014 the text 'GPT IMAGE 2' in large bold white sans-serif font, surrounded by thin orange line art showing diverse outputs: a constellation portrait, a risograph print, a brutalist poster, a film grain photo, an infographic \u2014 all rendered as small minimalist thumbnails floating around the text. Subtitle below in gray: 'AI Image Generation Skill for Claude Code'. Clean, technical, high contrast.",
"preset": null,
"platform": "blog",
"thinking": "off",
"provider": "openai",
"n": 1,
"output": "/Users/glebkalinin/ai_projects/claude-skills/gpt-image-2/screenshot.png",
"project": null,
"estimated_cost": 0.19
}#!/usr/bin/env python3
"""GPT Image 2 — OpenAI Image Generation Tool
A CLI wrapper around OpenAI's GPT Image 2 model.
Supports style presets, platform-specific sizing, thinking mode, variants,
image editing, seed locking, cost controls, and OpenRouter routing.
Usage:
gpt_image_2.py [flags] "prompt" [output.png]
gpt_image_2.py init # onboarding wizard
gpt_image_2.py again # regenerate last
gpt_image_2.py history [-n 10] # show history
gpt_image_2.py list-presets
gpt_image_2.py list-platforms
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
try:
import yaml
except ImportError:
print("Error: PyYAML not installed. Run: pip3 install pyyaml", file=sys.stderr)
sys.exit(1)
SKILL_DIR = Path(__file__).resolve().parent.parent
PRESETS_FILE = SKILL_DIR / "presets.yaml"
PLATFORMS_FILE = SKILL_DIR / "platforms.yaml"
CENTRAL_SECRETS = SKILL_DIR.parent / "secrets.enc.yaml"
LOCAL_SECRETS = SKILL_DIR / "secrets.enc.yaml"
CONFIG_DIR = Path.home() / ".config" / "gpt-image-2"
CONFIG_FILE = CONFIG_DIR / "config.yaml"
HISTORY_FILE = CONFIG_DIR / "history.jsonl"
LAST_RUN_FILE = CONFIG_DIR / "last.json"
MODEL = "gpt-image-2"
THINKING_LEVELS = ("off", "low", "medium", "high")
PROVIDERS = {
"openai": {
"url": "https://api.openai.com/v1/images/generations",
"edit_url": "https://api.openai.com/v1/images/edits",
"key_env": "OPENAI_API_KEY",
"key_sops": "OPENAI_API_KEY",
},
"openrouter": {
"url": "https://openrouter.ai/api/v1/images/generations",
"edit_url": "https://openrouter.ai/api/v1/images/edits",
"key_env": "OPENROUTER_API_KEY",
"key_sops": "OPENROUTER_API_KEY",
},
}
# Cost per image by quality and thinking level (April 2026 pricing)
COST_PER_IMAGE = {
"high": {"off": 0.21, "low": 0.25, "medium": 0.32, "high": 0.42},
"medium": {"off": 0.05, "low": 0.07, "medium": 0.09, "high": 0.14},
"low": {"off": 0.006, "low": 0.01, "medium": 0.015, "high": 0.025},
}
CONFIRM_THRESHOLD = 0.50
def estimate_cost(quality: str, thinking: str, n: int) -> float:
return COST_PER_IMAGE.get(quality, COST_PER_IMAGE["high"]).get(thinking, 0.21) * n
def cost_per_unit(quality: str, thinking: str) -> float:
return COST_PER_IMAGE.get(quality, COST_PER_IMAGE["high"]).get(thinking, 0.21)
# ---------- Config & secrets ----------
def load_config() -> dict[str, Any]:
if CONFIG_FILE.exists():
with CONFIG_FILE.open() as f:
return yaml.safe_load(f) or {}
return {}
def get_api_key(provider: str) -> str | None:
prov = PROVIDERS[provider]
if os.environ.get(prov["key_env"]):
return os.environ[prov["key_env"]]
if shutil.which("sops") is None:
return None
for secrets_path in (LOCAL_SECRETS, CENTRAL_SECRETS):
if secrets_path.exists():
try:
result = subprocess.run(
["sops", "--decrypt", "--extract", f'["{prov["key_sops"]}"]', str(secrets_path)],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
continue
return None
# ---------- Presets ----------
def load_presets() -> dict[str, dict]:
if PRESETS_FILE.exists():
with PRESETS_FILE.open() as f:
return yaml.safe_load(f) or {}
return {}
def load_platforms() -> dict[str, dict]:
if PLATFORMS_FILE.exists():
with PLATFORMS_FILE.open() as f:
return yaml.safe_load(f) or {}
return {}
def compose_prompt(user_prompt: str, preset_name: str | None) -> tuple[str, str | None, str | None]:
"""Return (final_prompt, preset_thinking_level, preset_reference_path).
A preset may declare a `reference:` path (relative to the skill dir); it is
auto-attached as a style anchor unless the user passes their own --reference.
"""
if not preset_name:
return user_prompt, None, None
presets = load_presets()
if preset_name not in presets:
print(f"Error: unknown preset '{preset_name}'. Available: {', '.join(presets.keys())}", file=sys.stderr)
sys.exit(1)
preset = presets[preset_name]
prompt = preset["prompt"].replace("{subject}", user_prompt)
ref = preset.get("reference")
if ref and not Path(ref).is_absolute():
ref = str((SKILL_DIR / ref).resolve())
return prompt, preset.get("thinking"), ref
# ---------- Preflight: catch contradictions in the COMPOSED prompt ----------
# Generation is expensive, so before spending we resolve the final prompt (preset
# + subject) and check it for internal contradictions — most often a preset that
# hard-codes something the subject overrides (e.g. the `editorial` preset forces
# "pure black background" while the subject asks for a warm off-white ground).
# Preferred check is a fast Haiku call via the `llm` CLI; if that isn't available
# we fall back to a small static heuristic. Conflicts abort before any API spend
# unless --force is passed (or --no-preflight skips the check entirely).
_PREFLIGHT_Q = (
"You are a QA check for an image-generation prompt. The text below is the FINAL "
"composed prompt (a style preset may have been merged with a user subject). List ONLY "
"genuine internal CONTRADICTIONS that would make the output wrong — e.g. two different "
"backgrounds (preset says 'pure black background' but subject asks for 'warm off-white'), "
"clashing colour palettes, 'no text' alongside a requested headline/caption, or conflicting "
"orientation/aspect. Ignore mere richness or long descriptions. If there are NO real "
"contradictions, reply with exactly: OK. Otherwise reply with one short bullet per conflict.\n\n"
"PROMPT:\n"
)
def _llm_check(text: str) -> tuple[str | None, str]:
"""Ask an LLM (preferring Haiku) via the `llm` CLI to QA the prompt.
Tries Haiku model aliases first; if those fail (e.g. no Anthropic credit),
falls back to whatever `llm` default model is configured (often a cheap
OpenAI model). Returns (reply, source_label); (None, "") if `llm` is
unavailable or every attempt errors."""
import shutil
import subprocess
if not shutil.which("llm"):
return None, ""
attempts = [("haiku", ["-m", "claude-haiku-4.5"]),
("haiku", ["-m", "claude-3.5-haiku"]),
("llm-default", [])] # whatever `llm` is configured to use
for label, model_args in attempts:
try:
r = subprocess.run(["llm", *model_args, _PREFLIGHT_Q + text],
capture_output=True, text=True, timeout=45)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip(), label
except Exception:
continue
return None, ""
def _heuristic_conflicts(prompt: str) -> list[str]:
"""Dependency-free fallback: catch the common hard-coded contradictions."""
p = prompt.lower()
out = []
bg_dark = ("black background" in p) or ("on pure black" in p) or ("dark background" in p)
bg_light = any(w in p for w in ("off-white", "offwhite", "warm white", "cream",
"parchment", "white background", "light background",
"beige", "ivory"))
if bg_dark and bg_light:
out.append("Conflicting backgrounds: the prompt asks for both a dark/black background "
"and a light/off-white/cream one (often a preset hard-codes 'black background').")
no_text = any(w in p for w in ("no text", "no labels", "no legible text"))
wants_text = any(w in p for w in ("headline", "title card", "caption reads",
'with the text', "the words "))
if no_text and wants_text:
out.append("Conflicting text directives: 'no text' but also a requested headline/caption.")
return out
def preflight_conflicts(final_prompt: str) -> tuple[list[str], str]:
"""Return (conflicts, source). Prefer an LLM (Haiku); fall back to a static heuristic."""
reply, src = _llm_check(final_prompt)
if reply is not None:
if reply.strip().upper().startswith("OK"):
return [], src
lines = [ln.strip(" -•\t*") for ln in reply.splitlines() if ln.strip()]
return [ln for ln in lines if ln], src
return _heuristic_conflicts(final_prompt), "heuristic"
# ---------- Image I/O ----------
def encode_image(path: str) -> str:
p = Path(path)
if not p.exists():
print(f"Error: file not found: {path}", file=sys.stderr)
sys.exit(1)
with open(p, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def save_image(b64_data: str, output_path: Path) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "wb") as f:
f.write(base64.b64decode(b64_data))
def platform_fit(image_path: Path, width: int, height: int) -> None:
if not shutil.which("magick"):
print("Warning: ImageMagick not found, skipping platform resize", file=sys.stderr)
return
subprocess.run(
["magick", str(image_path), "-resize", f"{width}x{height}^",
"-gravity", "center", "-extent", f"{width}x{height}", str(image_path)],
check=True, capture_output=True,
)
def make_contact_sheet(images: list[Path], output: Path, cols: int = 3) -> None:
if not shutil.which("magick"):
print("Warning: ImageMagick not found, skipping contact sheet", file=sys.stderr)
return
subprocess.run(
["magick", "montage"] + [str(p) for p in images] +
["-geometry", "+4+4", "-tile", f"{cols}x", str(output)],
check=True, capture_output=True,
)
# ---------- API ----------
MIME_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
def _build_multipart(fields: list[tuple[str, str | bytes, str | None]]) -> tuple[bytes, str]:
"""Build multipart/form-data body. Each field is (name, value, filename_or_None)."""
import uuid
boundary = uuid.uuid4().hex
lines: list[bytes] = []
for name, value, filename in fields:
lines.append(f"--{boundary}".encode())
if filename:
ext = Path(filename).suffix.lower()
mime = MIME_TYPES.get(ext, "image/png")
lines.append(f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode())
lines.append(f"Content-Type: {mime}".encode())
else:
lines.append(f'Content-Disposition: form-data; name="{name}"'.encode())
lines.append(b"")
lines.append(value if isinstance(value, bytes) else value.encode())
lines.append(f"--{boundary}--".encode())
body = b"\r\n".join(lines)
content_type = f"multipart/form-data; boundary={boundary}"
return body, content_type
def api_request(
prompt: str,
provider: str,
api_key: str,
thinking: str = "off",
size: str = "1024x1024",
quality: str = "high",
n: int = 1,
seed: int | None = None,
edit_image: str | None = None,
reference_images: list[str] | None = None,
) -> list[str]:
"""Call the OpenAI/OpenRouter image generation API. Returns list of base64 images."""
prov = PROVIDERS[provider]
is_edit = bool(edit_image or reference_images)
if is_edit:
url = prov["edit_url"]
fields: list[tuple[str, str | bytes, str | None]] = [
("model", MODEL, None),
("prompt", prompt, None),
("n", str(n), None),
("size", size, None),
("quality", quality, None),
]
# seed parameter reserved for future API support
if edit_image:
with open(edit_image, "rb") as f:
fields.append(("image[]", f.read(), Path(edit_image).name))
if reference_images:
for ref in reference_images:
with open(ref, "rb") as f:
fields.append(("image[]", f.read(), Path(ref).name))
data, content_type = _build_multipart(fields)
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": content_type,
}
else:
url = prov["url"]
body: dict[str, Any] = {
"model": MODEL,
"prompt": prompt,
"n": n,
"size": size,
"quality": quality,
"output_format": "png",
}
# thinking parameter reserved for future API support
# (not yet accepted on /v1/images/generations endpoint)
# seed parameter reserved for future API support
data = json.dumps(body).encode("utf-8")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
if provider == "openrouter":
headers["HTTP-Referer"] = "https://github.com/glebis/claude-skills"
headers["X-Title"] = "gpt-image-2-skill"
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
max_retries = 4
for attempt in range(max_retries):
try:
with urllib.request.urlopen(req, timeout=300) as resp:
result = json.loads(resp.read().decode("utf-8"))
resp_data = result.get("data")
if not resp_data:
print("Error: API returned no image data.", file=sys.stderr)
sys.exit(1)
images = []
for item in resp_data:
b64 = item.get("b64_json")
if b64:
images.append(b64)
if not images:
print("Error: API response missing b64_json fields.", file=sys.stderr)
sys.exit(1)
return images
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace") if e.fp else ""
if e.code == 429 or e.code >= 500:
if attempt < max_retries - 1:
wait = 2 ** (attempt + 1)
label = "Rate limited" if e.code == 429 else f"Server error {e.code}"
print(f"{label}, retrying in {wait}s (attempt {attempt + 1}/{max_retries})...", file=sys.stderr)
time.sleep(wait)
else:
print(f"Failed after {max_retries} attempts. Last error {e.code}: {error_body}", file=sys.stderr)
sys.exit(1)
else:
print(f"Error {e.code}: {error_body}", file=sys.stderr)
sys.exit(1)
except urllib.error.URLError as e:
if attempt < max_retries - 1:
wait = 2 ** (attempt + 1)
print(f"Network error: {e.reason}, retrying in {wait}s...", file=sys.stderr)
time.sleep(wait)
else:
print(f"Failed after {max_retries} attempts: {e.reason}", file=sys.stderr)
sys.exit(1)
return []
# ---------- History ----------
@dataclass
class HistoryEntry:
timestamp: str
prompt: str
preset: str | None
platform: str | None
thinking: str
quality: str
provider: str
n: int
seed: int | None
output: str
project: str | None
estimated_cost: float | None
def save_history(entry: HistoryEntry) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
with HISTORY_FILE.open("a") as f:
f.write(json.dumps(asdict(entry)) + "\n")
with LAST_RUN_FILE.open("w") as f:
json.dump(asdict(entry), f, indent=2)
def load_history(n: int = 20, project: str | None = None) -> list[dict]:
if not HISTORY_FILE.exists():
return []
entries = []
with HISTORY_FILE.open() as f:
for line in f:
line = line.strip()
if not line:
continue
entry = json.loads(line)
if project and entry.get("project") != project:
continue
entries.append(entry)
return entries[-n:]
def load_last_run() -> dict | None:
if not LAST_RUN_FILE.exists():
return None
with LAST_RUN_FILE.open() as f:
return json.load(f)
# ---------- Metadata ----------
def save_metadata(output_path: Path, entry: HistoryEntry) -> None:
meta_path = output_path.with_suffix(".json")
with meta_path.open("w") as f:
json.dump(asdict(entry), f, indent=2)
# ---------- Init wizard ----------
def cmd_init():
print("🔧 GPT Image 2 — Setup Wizard\n")
deps = {"sops": shutil.which("sops"), "age": shutil.which("age"), "magick": shutil.which("magick")}
for name, path in deps.items():
status = f"✅ {path}" if path else "❌ not found"
print(f" {name}: {status}")
if not deps["magick"]:
print("\n⚠ ImageMagick not found. Platform resizing and contact sheets will be unavailable.")
print(" Install: brew install imagemagick")
print()
for provider_name in PROVIDERS:
key = get_api_key(provider_name)
if key:
masked = key[:8] + "..." + key[-4:]
print(f" {provider_name} key: ✅ {masked}")
else:
print(f" {provider_name} key: ❌ not found")
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
defaults = load_config()
if not defaults:
defaults = {
"provider": "openai",
"thinking": "off",
"quality": "high",
"size": "1024x1024",
}
with CONFIG_FILE.open("w") as f:
yaml.dump(defaults, f, default_flow_style=False)
print(f"\n✅ Config saved to {CONFIG_FILE}")
else:
print(f"\n✅ Config already exists at {CONFIG_FILE}")
print("\n📊 Pricing (per image):")
print(" quality=low: $0.006 (draft) — fast iteration")
print(" quality=medium: $0.05 — good for review")
print(" quality=high: $0.21 (default) — production")
print(" + thinking adds 20-100% on top")
print("\nReady! Try: scripts/gpt_image_2.py \"a cat astronaut\" ./cat.png")
# ---------- List commands ----------
def cmd_list_presets():
presets = load_presets()
if not presets:
print("No presets found.")
return
print("Available presets:\n")
for name, info in presets.items():
thinking = f" [thinking: {info['thinking']}]" if info.get("thinking") else ""
print(f" {name:16s} {info['description']}{thinking}")
def cmd_list_platforms():
platforms = load_platforms()
if not platforms:
print("No platforms found.")
return
print("Available platforms:\n")
for name, info in platforms.items():
print(f" {name:16s} {info['width']}×{info['height']} ({info['description']})")
# ---------- Main generate ----------
def cmd_generate(args):
config = load_config()
provider = args.provider or config.get("provider", "openai")
api_key = get_api_key(provider)
if not api_key:
print(f"Error: No API key found for {provider}.", file=sys.stderr)
print(f"Set {PROVIDERS[provider]['key_env']} or run: scripts/gpt_image_2.py init", file=sys.stderr)
sys.exit(1)
prompt, preset_thinking, preset_reference = compose_prompt(args.prompt, args.preset)
# A preset-declared reference auto-attaches unless the user supplied their own.
if preset_reference and not args.reference:
if Path(preset_reference).exists():
args.reference = [preset_reference]
else:
print(f"Warning: preset reference not found, skipping: {preset_reference}", file=sys.stderr)
thinking = args.thinking or preset_thinking or config.get("thinking", "off")
quality = args.quality or config.get("quality", "high")
size = args.size or config.get("size", "1024x1024")
n = args.n or 1
seed = getattr(args, "seed", None)
is_draft = getattr(args, "draft", False)
if is_draft:
quality = "low"
size = "1024x1024"
output_path = Path(args.output) if args.output else Path(f"./gpt-image-2-{datetime.now().strftime('%Y%m%d-%H%M%S')}.png")
if args.project:
base_dir = Path.home() / "gpt-image-2" / "outputs" / args.project
slug = re.sub(r'[^a-z0-9]+', '-', args.prompt.lower()[:40]).strip('-')
output_path = base_dir / f"{datetime.now().strftime('%Y%m%d')}-{slug}.png"
cost = estimate_cost(quality, thinking, n)
per = cost_per_unit(quality, thinking)
mode_label = "DRAFT" if is_draft else quality.upper()
if args.dry_run:
print(f"Mode: {mode_label}")
print(f"Prompt: {prompt}")
print(f"Provider: {provider}")
print(f"Thinking: {thinking}")
print(f"Quality: {quality}")
print(f"Size: {size}")
print(f"N: {n}")
if seed is not None:
print(f"Seed: {seed}")
print(f"Output: {output_path}")
print(f"Est. cost: ${cost:.3f}")
return
if args.estimate:
print(f"Estimated cost ({mode_label}): ${cost:.3f} ({n} image{'s' if n > 1 else ''} × ~${per:.3f}/image, quality={quality}, thinking={thinking})")
return
# Preflight: resolve the prompt and check for contradictions BEFORE spending.
if not getattr(args, "no_preflight", False):
print(f"\n— resolved prompt ({len(prompt)} chars) —\n{prompt}\n", file=sys.stderr)
conflicts, src = preflight_conflicts(prompt)
if conflicts:
print(f"⚠ preflight ({src}) found {len(conflicts)} possible prompt conflict(s):", file=sys.stderr)
for c in conflicts:
print(f" • {c}", file=sys.stderr)
if not getattr(args, "force", False):
print("Aborting before any generation spend. Fix the prompt (or the preset), then "
"re-run — or pass --force to generate anyway, or --no-preflight to skip this check.",
file=sys.stderr)
sys.exit(2)
print(" …--force set; generating anyway.", file=sys.stderr)
else:
print(f"✓ preflight ({src}): no contradictions found.", file=sys.stderr)
no_confirm = getattr(args, "yes", False)
if not no_confirm and cost >= CONFIRM_THRESHOLD:
print(f"⚠ Estimated cost: ${cost:.2f} ({n} × ~${per:.3f}/image, {mode_label})")
try:
answer = input("Proceed? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = ""
if answer not in ("y", "yes"):
print("Cancelled.", file=sys.stderr)
sys.exit(0)
print(f"Generating {mode_label} with {provider} (thinking: {thinking}, quality: {quality}, size: {size}{f', seed: {seed}' if seed else ''})...", file=sys.stderr)
images = api_request(
prompt=prompt,
provider=provider,
api_key=api_key,
thinking=thinking,
size=size,
quality=quality,
n=n,
seed=seed,
edit_image=args.edit,
reference_images=args.reference,
)
if not images:
print("Error: No images returned.", file=sys.stderr)
sys.exit(1)
saved_paths = []
if len(images) == 1:
save_image(images[0], output_path)
saved_paths.append(output_path)
print(f"✅ {output_path}")
else:
stem = output_path.stem
suffix = output_path.suffix
parent = output_path.parent
for i, img_data in enumerate(images, 1):
p = parent / f"{stem}-{i:02d}{suffix}"
save_image(img_data, p)
saved_paths.append(p)
print(f"✅ {p}")
contact = parent / f"{stem}-contact{suffix}"
make_contact_sheet(saved_paths, contact)
if contact.exists():
print(f"✅ {contact} (contact sheet)")
platform = None
if args.platform:
platforms = load_platforms()
if args.platform not in platforms:
print(f"Warning: unknown platform '{args.platform}', skipping resize", file=sys.stderr)
else:
plat = platforms[args.platform]
platform = args.platform
for p in saved_paths:
platform_fit(p, plat["width"], plat["height"])
print(f" Resized to {plat['width']}×{plat['height']} ({args.platform})")
actual_cost = estimate_cost(quality, thinking, len(images))
entry = HistoryEntry(
timestamp=datetime.now().isoformat(),
prompt=args.prompt,
preset=args.preset,
platform=platform,
thinking=thinking,
quality=quality,
provider=provider,
n=len(images),
seed=seed,
output=str(saved_paths[0] if len(saved_paths) == 1 else saved_paths[0].parent),
project=args.project,
estimated_cost=actual_cost,
)
save_history(entry)
for p in saved_paths:
save_metadata(p, entry)
print(f" Est. cost: ${actual_cost:.3f}")
# ---------- Again ----------
def cmd_again(args):
last = load_last_run()
if not last:
print("No previous run found.", file=sys.stderr)
sys.exit(1)
print(f"Re-running: \"{last['prompt']}\"")
args.prompt = last["prompt"]
args.preset = last.get("preset")
args.platform = last.get("platform")
args.thinking = last.get("thinking", "off")
args.provider = last.get("provider", "openai")
args.n = last.get("n", 1)
args.quality = last.get("quality", "high")
args.size = "1024x1024"
args.seed = last.get("seed")
args.edit = None
args.reference = None
args.project = last.get("project")
args.dry_run = False
args.estimate = False
args.draft = False
args.yes = False
args.output = None
cmd_generate(args)
# ---------- History ----------
def cmd_history(args):
entries = load_history(n=args.n, project=args.history_project)
if not entries:
print("No history found.")
return
for e in entries:
ts = e["timestamp"][:19]
prompt = e["prompt"][:50]
cost = f"${e.get('estimated_cost', 0):.3f}" if e.get("estimated_cost") else "?"
preset = f" [{e['preset']}]" if e.get("preset") else ""
q = e.get("quality", "high")
thinking = f" t:{e['thinking']}" if e.get("thinking", "off") != "off" else ""
seed_str = f" s:{e['seed']}" if e.get("seed") else ""
print(f" {ts} {cost:>7s} q:{q:<6s}{thinking}{seed_str} {prompt}{preset}")
# ---------- CLI ----------
def main():
if len(sys.argv) > 1 and sys.argv[1] == "init":
cmd_init()
return
if len(sys.argv) > 1 and sys.argv[1] == "list-presets":
cmd_list_presets()
return
if len(sys.argv) > 1 and sys.argv[1] == "list-platforms":
cmd_list_platforms()
return
parser = argparse.ArgumentParser(
description="GPT Image 2 — OpenAI Image Generation",
epilog="Commands: init, list-presets, list-platforms, again, history",
)
sub = parser.add_subparsers(dest="command")
gen_parser = argparse.ArgumentParser(
prog="gpt_image_2.py",
description="GPT Image 2 — Generate images from text prompts",
)
gen_parser.add_argument("prompt", nargs="?", help="Text prompt for image generation")
gen_parser.add_argument("output", nargs="?", help="Output file path (default: auto-named)")
gen_parser.add_argument("--preset", help="Style preset name")
gen_parser.add_argument("--platform", help="Platform preset for auto-sizing")
gen_parser.add_argument("--thinking", choices=THINKING_LEVELS, help="Thinking level (off/low/medium/high)")
gen_parser.add_argument("--provider", choices=list(PROVIDERS.keys()), help="API provider")
gen_parser.add_argument("--quality", choices=("low", "medium", "high"), help="Image quality")
gen_parser.add_argument("--size", help="Image size (e.g., 1024x1024, 1536x1024, 2000x1024)")
gen_parser.add_argument("--n", type=int, help="Number of variants (1-10)")
gen_parser.add_argument("--seed", type=int, help="Seed for reproducible output")
gen_parser.add_argument("--edit", help="Path to image to edit")
gen_parser.add_argument("--reference", action="append", help="Reference image for style (repeatable)")
gen_parser.add_argument("--project", help="Project name for organized output")
gen_parser.add_argument("--dry-run", action="store_true", help="Preview prompt without API call")
gen_parser.add_argument("--no-preflight", action="store_true", help="Skip the pre-generation prompt conflict check")
gen_parser.add_argument("--force", action="store_true", help="Generate even if preflight finds prompt conflicts")
gen_parser.add_argument("--estimate", action="store_true", help="Show cost estimate only")
gen_parser.add_argument("--draft", action="store_true", help="Draft mode: low quality, ~$0.006/image")
gen_parser.add_argument("-y", "--yes", action="store_true", help="Skip cost confirmation prompt")
sub_again = sub.add_parser("again", help="Re-run last generation")
sub_history = sub.add_parser("history", help="Show generation history")
sub_history.add_argument("-n", type=int, default=20, help="Number of entries to show")
sub_history.add_argument("--project", dest="history_project", help="Filter by project")
if len(sys.argv) <= 1 or sys.argv[1] in ("-h", "--help"):
parser.print_help()
return
if sys.argv[1] not in ("again", "history"):
args = gen_parser.parse_args()
if not args.prompt:
gen_parser.print_help()
sys.exit(1)
if args.n is not None and (args.n < 1 or args.n > 10):
print("Error: --n must be between 1 and 10", file=sys.stderr)
sys.exit(1)
cmd_generate(args)
else:
args = parser.parse_args()
if args.command == "again":
cmd_again(args)
elif args.command == "history":
cmd_history(args)
else:
parser.print_help()
if __name__ == "__main__":
main()