Skill: jancong/gpt-image-2-api (version 2.0.0)

--- name: gpt-image-2 description: Generate and edit images via OpenAI gpt-image-2 model. Agent-agnostic CLI — works with any AI agent (Hermes, Claude Code, Codex, OpenClaw, etc.). Supports configurable base_url/api_key, text-to-image and image-to-image editing. version: 2.0.0 author: AI Agent Toolkit license: MIT metadata: tags: [image, generation, openai, gpt-image-2, text-to-image, image-editing, agent-agnostic] related_skills: [gemini-image-gen] --- # gpt-image-2 Generate and edit images via OpenAI's gpt-image-2 model. **Agent-agnostic** — designed to work with any AI agent or standalone from the command line. ## Quick Start ```bash # 1. Initialize config (one-time) python3 gpt_image2.py config --init # 2. Edit the config to set your API key # ~/.config/gpt-image-2/config.json # 3. Generate python3 gpt_image2.py generate "A cute cat on a windowsill" -o ~/cat.png --quality low # 4. Edit python3 gpt_image2.py edit input.png "Change the sofa to green" -o ~/output.png ``` ## Configuration Config priority: `--config` flag > `--base-url`/`--api-key` flags > config file > environment variables > defaults. ### Config File Locations (in priority order) | Priority | Path | Notes | |----------|------|-------| | 1 | `$XDG_CONFIG_HOME/gpt-image-2/config.json` | XDG standard (recommended) | | 2 | `~/.config/gpt-image-2/config.json` | Default XDG fallback | | 3 | `~/.gpt-image-2-config.json` | Single-file fallback | | 4 | `~/.hermes/gpt-image-2-config.json` | Legacy Hermes compat | Use `python3 gpt_image2.py config --show` to see which config is active. ### Config File Format ```json { "base_url": "https://api.openai.com/v1", "api_key_env": "OPENAI_API_KEY" } ``` | Field | Type | Description | |-------|------|-------------| | `base_url` | string | API base URL. Default: `https://api.openai.com/v1` | | `api_key` | string | Plaintext API key (**not recommended** — visible in file) | | `api_key_env` | string | Environment variable name holding the key (**recommended**) | ### Environment Variables (fallback when no config file) | Variable | Purpose | |----------|---------| | `GPT_IMAGE2_API_KEY` | API key | | `GPT_IMAGE2_BASE_URL` | API base URL | ### Config Management Commands ```bash # Create template config python3 gpt_image2.py config --init # Show active config (keys are masked) python3 gpt_image2.py config --show # Overwrite config python3 gpt_image2.py config --init --force ``` ## CLI Reference ### generate — Text-to-Image ```bash python3 gpt_image2.py generate "prompt" [options] ``` | Option | Default | Description | |--------|---------|-------------| | `-o, --output` | `~/gpt-image2-output.png` | Output file path | | `--quality` | `auto` | `low` (~70s), `medium` (~120s), `high` (~276s) | | `--size` | `auto` | `1024x1024`, `1536x1024`, `1024x1536` | | `--format` | `png` | `png`, `jpeg`, `webp` | | `--n` | `1` | Number of images (1-10) | | `--timeout` | `600` | curl timeout in seconds | | `--config` | auto-detect | Explicit config file path | | `--base-url` | from config | Override API base URL | | `--api-key` | from config | Override API key (visible in ps!) | ### edit — Image-to-Image ```bash python3 gpt_image2.py edit <image_path> "edit prompt" [options] ``` | Option | Default | Description | |--------|---------|-------------| | `--mask` | none | PNG mask (transparent=edit area) | | `--moderation` | `auto` | `low` or `auto` | | (all generate options also apply) | | | ### config — Manage Configuration ```bash python3 gpt_image2.py config [--init] [--show] [--force] [--config PATH] ``` ## Script Location The script is at `scripts/gpt_image2.py` relative to this skill directory. To find it programmatically from any agent: ```bash # If installed as a Hermes skill: SCRIPT="$(dirname "$(readlink -f "$0")")/../skills/creative/gpt-image-2/scripts/gpt_image2.py" # Or copy/symlink it anywhere — it's self-contained with zero dependencies beyond stdlib + curl cp scripts/gpt_image2.py /usr/local/bin/gpt-image2 ``` The script has **zero pip dependencies** — only Python 3.8+ stdlib and `curl`. ## API Reference ### Generations (Text-to-Image) | Item | Value | |------|-------| | Endpoint | `POST {base_url}/images/generations` | | Auth | `Authorization: Bearer {api_key}` | | Content-Type | `application/json` | ### Edits (Image-to-Image) | Item | Value | |------|-------| | Endpoint | `POST {base_url}/images/edits` | | Auth | `Authorization: Bearer {api_key}` | | Content-Type | `multipart/form-data` | ### Parameters **Generations (JSON body):** | Param | Type | Required | Description | |-------|------|----------|-------------| | `model` | string | yes | `gpt-image-2` | | `prompt` | string | yes | Text description | | `n` | int | no | Number of images (default 1) | | `size` | string | no | `1024x1024`, `1536x1024`, `1024x1536` | | `quality` | string | no | `low`, `medium`, `high` (default `auto`) | | `format` | string | no | `png`, `jpg`, `webp` (default `png`) | **Edits (form-data):** | Param | Type | Required | Description | |-------|------|----------|-------------| | `model` | string | yes | `gpt-image-2` | | `prompt` | string | yes | Edit instruction | | `image` | file | yes | Source image (PNG, max 4 images) | | `n` | int | no | Number of outputs (default 1) | | `size` | string | no | `1024x1024`, `1536x1024`, `1024x1536`, or `auto` | | `quality` | string | no | `low`, `medium`, `high` (default `auto`) | ## Agent Integration Guide This skill is designed to be **agent-agnostic**. Any AI agent can use it by: 1. **Locate the script**: Find `gpt_image2.py` in the skill's `scripts/` directory 2. **Call via shell**: `python3 <path>/gpt_image2.py generate "prompt" -o output.png` 3. **Parse stdout**: The script prints `Saved: <path> (<size> KB)` on success ### Integration Examples **Hermes / Claude Code / Codex / OpenClaw:** ```bash python3 /path/to/gpt-image-2/scripts/gpt_image2.py generate "prompt" -o output.png --quality low ``` **From Python (any agent):** ```python import subprocess, json result = subprocess.run( ["python3", script_path, "generate", prompt, "-o", output_path, "--quality", "low"], capture_output=True, text=True, timeout=600 ) # Parse result.stdout for "Saved: <path>" ``` **From Node.js / TypeScript:** ```javascript const { execSync } = require('child_process'); const output = execSync(`python3 ${scriptPath} generate "${prompt}" -o ${outputPath}`); // Parse output.toString() for "Saved: ..." ``` ## Workflow: Agent Generates Images 1. **Always use the CLI script** — handles config resolution, auth security, and response parsing 2. **Use low quality for drafts**, high quality for final output 3. **For edits**: `--size auto` preserves original dimensions (recommended) 4. **The script outputs**: HTTP status, time elapsed, output file path and size 5. **Parse the output**: look for `Saved: <path>` lines to find generated files ## Workflow: Agent Edits Existing Images 1. Save or locate the source image path 2. Call `gpt_image2.py edit <image_path> "<edit_prompt>" --output <output_path>` 3. Edit endpoint can accept up to 4 images via repeated `--image` flags 4. Use `--size auto` to preserve original dimensions ## Important Pitfalls 1. **`--api-key` flag is visible in shell history and `ps aux`** — prefer config file (`api_key_env`) or environment variables. 2. **The edits endpoint does NOT support `response_format`** — always returns b64_json regardless. 3. **gpt-image-2 generations may time out on some relay endpoints** — use `--timeout` flag (default 600s). 4. **Prompt with special characters** — the script writes prompts to temp files internally, avoiding shell escaping issues. No need to worry about quoting. 5. **Authorization header is never passed via `-H`** — the script uses curl `-K` temp config file, deleted immediately after use. Keys never appear in `ps aux`. 6. **Config file permissions** — the script warns if config has group/other read permissions. Run `chmod 600 <config>` to fix. 7. **Zero pip dependencies** — the script only requires Python 3.8+ stdlib and `curl`. No installation step needed. 8. **Chinese text in prompts may not render correctly** — gpt-image-2's Chinese rendering is unstable; it often ignores Chinese constraints and outputs English text in images. Consider using Gemini for Chinese text rendering.

path: scripts/gpt_image2.py
content: #!/usr/bin/env python3
"""
gpt-image-2 CLI — Text-to-Image & Image-to-Image

Agent-agnostic: works with Hermes, Claude Code, OpenClaw, Codex, or any agent.
Config discovery: --config flag > XDG_CONFIG_HOME > ~/.config/ > ~/.hermes/ (legacy compat)

Usage:
  # Text-to-image
  python3 gpt_image2.py generate "A cute cat" -o cat.png --quality low

  # Image-to-image
  python3 gpt_image2.py edit input.png "Change sofa to green" -o out.png

  # Explicit config
  python3 gpt_image2.py generate "sunset" --config /path/to/config.json

  # Inline override
  python3 gpt_image2.py generate "sunset" --base-url https://relay.example.com/v1 --api-key sk-xxx
"""

from __future__ import annotations

import argparse
import base64
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Optional

DEFAULT_BASE_URL = "https://api.openai.com/v1"

# ── Config Discovery ──────────────────────────────────────────────
# Priority: --config flag > XDG_CONFIG_HOME/gpt-image-2/config.json
#           > ~/.config/gpt-image-2/config.json
#           > ~/.gpt-image-2-config.json  (single-file fallback)
#           > ~/.hermes/gpt-image-2-config.json  (legacy compat)

def _xdg_config_home() -> Path:
    return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))

def _config_candidates() -> list[Path]:
    """Return config file candidates in priority order."""
    xdg = _xdg_config_home()
    return [
        xdg / "gpt-image-2" / "config.json",
        Path.home() / ".gpt-image-2-config.json",
        Path.home() / ".hermes" / "gpt-image-2-config.json",  # legacy
    ]

def _find_config_path(explicit: str | None = None) -> Path | None:
    """Find the first existing config file, or return the explicit path."""
    if explicit:
        p = Path(explicit)
        if not p.exists():
            print(f"WARNING: Explicit config not found: {p}", file=sys.stderr)
        return p
    for p in _config_candidates():
        if p.exists():
            return p
    return None

def _default_config_path() -> Path:
    """Where to write a new config if none exists (XDG location)."""
    return _xdg_config_home() / "gpt-image-2" / "config.json"


def load_config(explicit_path: str | None = None) -> dict:
    """Load config from the best available location."""
    p = _find_config_path(explicit_path)
    if p is None or not p.exists():
        return {}
    # Warn on loose permissions
    try:
        mode = p.stat().st_mode & 0o777
        if mode & 0o077:
            import warnings
            warnings.warn(f"Config has loose permissions ({oct(mode)}). Run: chmod 600 {p}")
    except OSError:
        pass
    try:
        with open(p) as f:
            return json.load(f)
    except (json.JSONDecodeError, IOError) as e:
        print(f"WARNING: Failed to parse config {p}: {e}", file=sys.stderr)
        return {}


# ── Auth Resolution ───────────────────────────────────────────────

def get_base_url(cli_value: str | None = None, config: dict | None = None) -> str:
    """Priority: CLI > config.base_url > GPT_IMAGE2_BASE_URL env > default"""
    if cli_value:
        return cli_value.rstrip("/")
    if config is None:
        config = {}
    if config.get("base_url"):
        return config["base_url"].rstrip("/")
    env_val = os.environ.get("GPT_IMAGE2_BASE_URL", "")
    if env_val:
        return env_val.rstrip("/")
    return DEFAULT_BASE_URL


def get_api_key(cli_value: str | None = None, config: dict | None = None) -> str:
    """Priority: CLI > config.api_key > config.api_key_env > GPT_IMAGE2_API_KEY env"""
    if cli_value:
        return cli_value
    if config is None:
        config = {}
    if config.get("api_key"):
        return config["api_key"]
    if config.get("api_key_env"):
        return os.environ.get(config["api_key_env"], "")
    return os.environ.get("GPT_IMAGE2_API_KEY", "")


# ── Response Handling ─────────────────────────────────────────────

def decode_and_save(response_path: str, output_path: str) -> list[tuple[str, int]]:
    """Decode API b64_json response and save image(s). Returns [(path, size_bytes)]."""
    raw = Path(response_path).read_text(errors="replace")
    if not raw.strip():
        print("ERROR: Empty response from API (server may be down or timed out)", file=sys.stderr)
        sys.exit(1)
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        # Show first 500 chars of non-JSON response for debugging
        print(f"ERROR: Non-JSON response (first 500 chars): {raw[:500]}", file=sys.stderr)
        sys.exit(1)

    if "error" in data:
        print(f"API ERROR: {json.dumps(data['error'], ensure_ascii=False)}", file=sys.stderr)
        sys.exit(1)

    if "data" not in data or len(data["data"]) == 0:
        print("ERROR: No image data in response", file=sys.stderr)
        sys.exit(1)

    saved = []
    for i, item in enumerate(data["data"]):
        b64 = item.get("b64_json", "")
        if not b64:
            print(f"WARNING: Item {i} has no b64_json, skipping", file=sys.stderr)
            continue
        img_bytes = base64.b64decode(b64)

        if len(data["data"]) == 1:
            out = output_path
        else:
            p = Path(output_path)
            out = str(p.with_name(f"{p.stem}-{i}{p.suffix}"))

        with open(out, "wb") as f:
            f.write(img_bytes)
        saved.append((out, len(img_bytes)))

    for path, size in saved:
        print(f"Saved: {path} ({size / 1024:.1f} KB)")
    return saved


# ── curl helpers ──────────────────────────────────────────────────

def _curl_auth_header(api_key: str) -> str:
    """Write Authorization header to a temp curl-config file. Caller must unlink."""
    fd, path = tempfile.mkstemp(suffix=".headers")
    with os.fdopen(fd, "w") as f:
        f.write(f'-H "Authorization: Bearer {api_key}"\n')
    return path


def _curl_post_json(url: str, api_key: str, body: dict, resp_path: str, timeout: int) -> subprocess.CompletedProcess:
    """POST JSON body via curl, auth via -K temp file (not visible in ps)."""
    body_fd, body_path = tempfile.mkstemp(suffix=".json")
    with os.fdopen(body_fd, "w") as f:
        json.dump(body, f, ensure_ascii=False)

    header_path = _curl_auth_header(api_key)

    # Also add Content-Type via the same -K file
    with open(header_path, "a") as f:
        f.write('-H "Content-Type: application/json"\n')

    try:
        result = subprocess.run(
            [
                "curl", "-s", "-X", "POST", url,
                "-K", header_path,
                "-d", f"@{body_path}",
                "-o", resp_path,
                "-w", "\nHTTP_CODE:%{http_code}\nTIME:%{time_total}s",
            ],
            capture_output=True, text=True, timeout=timeout,
        )
        return result
    except subprocess.TimeoutExpired:
        print(f"ERROR: curl timed out after {timeout}s", file=sys.stderr)
        sys.exit(1)
    finally:
        for p in [body_path, header_path]:
            if os.path.exists(p):
                os.unlink(p)


def _curl_post_multipart(url: str, api_key: str, fields: list[tuple[str, str]],
                          resp_path: str, timeout: int) -> subprocess.CompletedProcess:
    """POST multipart/form-data via curl, auth via -K temp file."""
    header_path = _curl_auth_header(api_key)
    try:
        cmd = ["curl", "-s", "-X", "POST", url, "-K", header_path]
        for key, val in fields:
            cmd += ["-F", f"{key}={val}"]
        cmd += ["-o", resp_path, "-w", "\nHTTP_CODE:%{http_code}\nTIME:%{time_total}s"]

        result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return result
    except subprocess.TimeoutExpired:
        print(f"ERROR: curl timed out after {timeout}s", file=sys.stderr)
        sys.exit(1)
    finally:
        if os.path.exists(header_path):
            os.unlink(header_path)


# ── Commands ──────────────────────────────────────────────────────

def cmd_generate(args):
    """Text-to-image: POST /images/generations"""
    config = load_config(args.config)
    base_url = get_base_url(args.base_url, config)
    [REDACTED_SECRET], config)

    if not [REDACTED_SECRET]"ERROR: No API key found. Use --api-key, config.json, or GPT_IMAGE2_API_KEY env.", file=sys.stderr)
        sys.exit(1)

    body: dict = {"model": "gpt-image-2", "prompt": args.prompt, "n": args.n}
    if args.size:
        body["size"] = args.size
    if args.quality:
        body["quality"] = args.quality
    if args.format:
        body["format"] = args.format

    url = f"{base_url}/images/generations"
    output = args.output or os.path.expanduser("~/gpt-image2-output.png")
    _, resp_path = tempfile.mkstemp(suffix=".json")

    print(f"Calling: POST {url}")
    print(f"Prompt: {args.prompt[:100]}{'...' if len(args.prompt) > 100 else ''}")
    print(f"Quality: {args.quality or 'auto'}, Size: {args.size or 'auto'}, N: {args.n}")

    start = time.time()
    try:
        result = _curl_post_json(url, api_key, body, resp_path, args.timeout)
        elapsed = time.time() - start
        print(result.stdout.strip() if result.stdout else "")

        if result.returncode != 0:
            print(f"curl failed (exit {result.returncode})", file=sys.stderr)
            sys.exit(1)

        decode_and_save(resp_path, output)
    finally:
        if os.path.exists(resp_path):
            os.unlink(resp_path)

    print(f"Done in {elapsed:.1f}s")


def cmd_edit(args):
    """Image-to-image: POST /images/edits"""
    config = load_config(args.config)
    base_url = get_base_url(args.base_url, config)
    [REDACTED_SECRET], config)

    if not [REDACTED_SECRET]"ERROR: No API key found. Use --api-key, config.json, or GPT_IMAGE2_API_KEY env.", file=sys.stderr)
        sys.exit(1)

    if not os.path.exists(args.image):
        print(f"ERROR: Image not found: {args.image}", file=sys.stderr)
        sys.exit(1)

    # Write prompt to temp file to avoid shell escaping issues
    prompt_fd, prompt_path = tempfile.mkstemp(suffix=".txt")
    with os.fdopen(prompt_fd, "w") as f:
        f.write(args.prompt)

    try:
        with open(prompt_path, "r") as f:
            prompt_text = f.read()
    finally:
        if os.path.exists(prompt_path):
            os.unlink(prompt_path)

    url = f"{base_url}/images/edits"
    output = args.output or os.path.expanduser("~/gpt-image2-edit-output.png")
    _, resp_path = tempfile.mkstemp(suffix=".json")

    print(f"Calling: POST {url}")
    print(f"Image: {args.image}")
    print(f"Prompt: {args.prompt[:100]}{'...' if len(args.prompt) > 100 else ''}")
    print(f"Quality: {args.quality or 'auto'}, Size: {args.size or 'auto'}")

    start = time.time()
    try:
        # Build multipart fields
        fields = [
            ("image", f"@{args.image}"),
            ("prompt", prompt_text),
            ("model", "gpt-image-2"),
            ("n", str(args.n)),
        ]
        if args.quality:
            fields.append(("quality", args.quality))
        if args.size:
            fields.append(("size", args.size))
        if args.mask:
            fields.append(("mask", f"@{args.mask}"))
        if args.moderation:
            fields.append(("moderation", args.moderation))

        result = _curl_post_multipart(url, api_key, fields, resp_path, args.timeout)
        elapsed = time.time() - start
        print(result.stdout.strip() if result.stdout else "")

        if result.returncode != 0:
            print(f"curl failed (exit {result.returncode})", file=sys.stderr)
            sys.exit(1)

        decode_and_save(resp_path, output)
    finally:
        if os.path.exists(resp_path):
            os.unlink(resp_path)

    print(f"Done in {elapsed:.1f}s")


def cmd_config(args):
    """Show or initialize config file"""
    found = _find_config_path(args.config)
    default = _default_config_path()

    if args.init:
        # Create a template config
        default.parent.mkdir(parents=True, exist_ok=True)
        template = {
            "base_url": "https://api.openai.com/v1",
            "api_key_env": "OPENAI_API_KEY",
        }
        if default.exists() and not args.force:
            print(f"Config already exists: {default}  (use --force to overwrite)")
            sys.exit(1)
        with open(default, "w") as f:
            json.dump(template, f, indent=2)
        os.chmod(default, 0o600)
        print(f"Created: {default}")
        print("Edit it to set your API key or base_url.")
    elif args.show:
        if found:
            print(f"Config file: {found}")
            with open(found) as f:
                data = json.load(f)
            # Mask api_key for safety
            if "api_key" in data:
                k = data["api_key"]
                data["api_key"] = k[:4] + "..." + k[-4:] if len(k) > 8 else "***"
            print(json.dumps(data, indent=2))
        else:
            print("No config file found. Run: gpt_image2.py config --init")
    else:
        print(f"Default config location: {default}")
        if found:
            print(f"Active config: {found}")
        else:
            print("No config file found. Run: gpt_image2.py config --init")


# ── CLI ───────────────────────────────────────────────────────────

def add_common_args(p):
    """Add shared arguments to a subparser."""
    p.add_argument("--config", help="Explicit path to config.json")
    p.add_argument("--base-url", help="API base URL (default: https://api.openai.com/v1)")
    p.add_argument("--api-key", help="API key (WARNING: visible in shell history; prefer config/env)")
    p.add_argument("--output", "-o", help="Output file path")
    p.add_argument("--quality", choices=["low", "medium", "high", "auto"], help="Image quality")
    p.add_argument("--size", help="Image size (e.g. 1024x1024, 1536x1024, auto)")
    p.add_argument("--n", type=int, default=1, help="Number of images (1-10)")
    p.add_argument("--timeout", type=int, default=600, help="curl timeout in seconds (default: 600)")


def main():
    parser = argparse.ArgumentParser(
        description="gpt-image-2 image generation and editing CLI (agent-agnostic)"
    )

    subparsers = parser.add_subparsers(dest="command", required=True)

    # generate
    gen = subparsers.add_parser("generate", help="Text-to-image generation")
    gen.add_argument("prompt", help="Image description (max 1000 chars)")
    gen.add_argument("--format", choices=["png", "jpeg", "webp"], help="Output format")
    add_common_args(gen)

    # edit
    edt = subparsers.add_parser("edit", help="Image-to-image editing")
    edt.add_argument("image", help="Input image path")
    edt.add_argument("prompt", help="Edit instruction (max 32000 chars)")
    edt.add_argument("--mask", help="PNG mask image (transparent=edit area)")
    edt.add_argument("--moderation", choices=["low", "auto"], help="Moderation level")
    add_common_args(edt)

    # config
    cfg = subparsers.add_parser("config", help="Show or initialize config")
    cfg.add_argument("--init", action="store_true", help="Create template config")
    cfg.add_argument("--show", action="store_true", help="Show active config (masks keys)")
    cfg.add_argument("--force", action="store_true", help="Overwrite existing config with --init")
    cfg.add_argument("--config", help="Explicit config path (for --show)")

    args = parser.parse_args()

    if args.command == "generate":
        cmd_generate(args)
    elif args.command == "edit":
        cmd_edit(args)
    elif args.command == "config":
        cmd_config(args)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)