---
name: flicky
description: Generate AND edit images using Google Nano Banana 2 (Gemini 3.1 Flash Image) via OpenRouter. TRIGGER when the user asks to generate, create, make, draw, render, produce, edit, restyle, modify, retouch, alter, change, enhance, combine, or remix any image, photo, picture, illustration, render, artwork, logo, icon, or visual. Also triggers on phrases like "give me a picture of", "make me an image", "generate a photo", "render this", "draw me", "edit this image", "change the background of", "make this look like", "combine these images", "flicky". Supports text-to-image, image editing with reference images, multi-image composition, parallel variations, and auto-opens results in Preview. Reads OPENROUTER_API_KEY from environment.
---


# flicky — image generation + editing skill

Generates AND edits images using **Google Nano Banana 2** (`google/gemini-3.1-flash-image-preview`) via the OpenRouter API. Saves PNGs to disk, auto-opens them in Preview, and returns the file paths.

## Setup

On first use, you MUST create the `generate.py` script from the embedded source code below. Write it to `~/.claude/skills/flicky/generate.py` and make it executable (`chmod +x`).

### Prerequisites

The user's OpenRouter API key must be available as the `OPENROUTER_API_KEY` environment variable. If it is not set, tell the user:

```
export OPENROUTER_API_KEY="sk-or-v1-..."
```

...and ask them to add it to their shell profile (`~/.zshrc` or `~/.bashrc`) so it persists.

You can get a key at https://openrouter.ai/keys — the model `google/gemini-3.1-flash-image-preview` is very cheap.

## When to use

**Text-to-image:**
- "make me a picture of a cyberpunk cat"
- "generate a logo for my band"
- "draw me a sunset"
- "render a 3d model of a skull"

**Image editing (when user provides a reference image):**
- "make this photo look like night time" + path to image
- "change the background to a beach" + ref
- "remove the person on the left" + ref
- "restyle this as an oil painting" + ref

**Multi-image composition:**
- "put this character into this scene" + 2 refs
- "combine these two outfits" + 2 refs

**Variations:**
- "give me 4 versions of [prompt]" → use `--count 4`

Do NOT use this for non-visual requests.

## How to invoke

Basic text-to-image:

```bash
python3 ~/.claude/skills/flicky/generate.py "PROMPT HERE"
```

**Edit a reference image:**

```bash
python3 ~/.claude/skills/flicky/generate.py "make it night time with neon lighting" \
  --ref ~/Pictures/photo.jpg
```

**Combine multiple reference images:**

```bash
python3 ~/.claude/skills/flicky/generate.py "put this character in this setting" \
  --ref ~/Pictures/character.png \
  --ref ~/Pictures/scene.png
```

**Generate parallel variations:**

```bash
python3 ~/.claude/skills/flicky/generate.py "demonic neon skull logo" --count 4
```

**Full options:**

```bash
python3 ~/.claude/skills/flicky/generate.py "PROMPT" \
  --aspect 16:9 \
  --size 2K \
  --count 4 \
  --ref ~/img1.png \
  --ref ~/img2.png \
  --out ~/Pictures/flicky \
  --no-open
```

| Flag | Default | Notes |
|------|---------|-------|
| `--aspect` | `1:1` | `1:1`, `16:9`, `9:16`, `4:3`, `3:4`, `21:9`. Ignored when refs are passed. |
| `--size` | `2K` | `1K` or `2K` |
| `--count` | `1` | Variations generated in parallel (1-8). |
| `--ref` | (none) | Reference image path. Repeat for multiple refs. |
| `--out` | `~/Pictures/flicky` | Output directory |
| `--no-open` | off | Skip auto-opening in Preview after save |

## Output

The script prints the absolute path of the saved PNG on success. After running, **always open the image for the user** with:

```bash
open "/path/to/file.png"
```

## Error handling

- Missing `OPENROUTER_API_KEY` → script exits 2, tells the user how to set it
- API error → script exits 1 and prints the JSON error response
- No image in response → script exits 3

---

## generate.py — embedded source

**On first use, create this file at `~/.claude/skills/flicky/generate.py` and `chmod +x` it.**

```python
#!/usr/bin/env python3
"""
flicky — image generation + editing via OpenRouter (Google Nano Banana 2)
Model: google/gemini-3.1-flash-image-preview
"""
import argparse
import base64
import json
import mimetypes
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MODEL = "google/gemini-3.1-flash-image-preview"
DEFAULT_OUT = Path.home() / "Pictures" / "flicky"
MAX_PARALLEL = 4


def slugify(text: str, max_len: int = 40) -> str:
    s = re.sub(r"[^a-zA-Z0-9]+", "-", text.lower()).strip("-")
    return s[:max_len] or "image"


def die(code: int, msg: str) -> None:
    print(f"flicky error: {msg}", file=sys.stderr)
    sys.exit(code)


def encode_image_to_data_url(path: str) -> str:
    p = Path(path).expanduser()
    if not p.exists():
        die(2, f"reference image not found: {p}")
    if not p.is_file():
        die(2, f"reference path is not a file: {p}")
    mime = mimetypes.guess_type(str(p))[0]
    if not mime or not mime.startswith("image/"):
        mime = "image/png"
    data = p.read_bytes()
    if len(data) > 20 * 1024 * 1024:
        die(2, f"reference image too large (>20MB): {p}")
    b64 = base64.b64encode(data).decode("ascii")
    return f"data:{mime};base64,{b64}"


def build_content(prompt: str, refs: list[str]):
    if not refs:
        return prompt
    blocks: list[dict] = [{"type": "text", "text": prompt}]
    for r in refs:
        blocks.append(
            {"type": "image_url", "image_url": {"url": encode_image_to_data_url(r)}}
        )
    return blocks


def call_api(prompt: str, refs: list[str], aspect: str, size: str, api_key: str) -> dict:
    payload = {
        "model": MODEL,
        "messages": [{"role": "user", "content": build_content(prompt, refs)}],
        "modalities": ["image", "text"],
        "image_config": {"aspect_ratio": aspect, "image_size": size},
    }
    req = urllib.request.Request(
        OPENROUTER_URL,
        data=json.dumps(payload).encode("utf-8"),
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "HTTP-Referer": "https://dem0n.vip/s/glo/flicky",
            "X-Title": "flicky (Claude Code skill)",
        },
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=180) as resp:
            body = resp.read().decode("utf-8")
    except urllib.error.HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {e.code} from OpenRouter: {err_body}")
    except urllib.error.URLError as e:
        raise RuntimeError(f"network error: {e.reason}")
    try:
        return json.loads(body)
    except json.JSONDecodeError:
        raise RuntimeError(f"non-JSON response: {body[:500]}")


def extract_images(result: dict) -> list[tuple[str, bytes]]:
    choices = result.get("choices") or []
    if not choices:
        raise RuntimeError(f"no choices in response: {json.dumps(result, indent=2)[:800]}")
    message = choices[0].get("message", {})
    images = message.get("images") or []
    if not images:
        raise RuntimeError(f"no images in response. message: {json.dumps(message, indent=2)[:1200]}")
    out: list[tuple[str, bytes]] = []
    for idx, img in enumerate(images):
        url = img.get("image_url", {}).get("url", "")
        if not url.startswith("data:image/"):
            continue
        try:
            header, b64 = url.split(",", 1)
        except ValueError:
            continue
        ext = "png"
        if "image/jpeg" in header:
            ext = "jpg"
        elif "image/webp" in header:
            ext = "webp"
        try:
            out.append((ext, base64.b64decode(b64)))
        except Exception:
            pass
    if not out:
        raise RuntimeError("no valid images decoded from response")
    return out


def save_images(images, out_dir, ts, slug, variant=None):
    paths = []
    for idx, (ext, data) in enumerate(images):
        parts = [f"flicky-{ts}", slug]
        if variant is not None:
            parts.append(f"v{variant}")
        if len(images) > 1:
            parts.append(f"i{idx + 1}")
        filename = "-".join(parts) + f".{ext}"
        path = out_dir / filename
        path.write_bytes(data)
        paths.append(str(path))
    return paths


def generate_one(prompt, refs, aspect, size, api_key, out_dir, ts, slug, variant):
    result = call_api(prompt, refs, aspect, size, api_key)
    images = extract_images(result)
    return save_images(images, out_dir, ts, slug, variant=variant)


def main():
    parser = argparse.ArgumentParser(description="Generate / edit images via OpenRouter")
    parser.add_argument("prompt", help="Image prompt or edit instruction")
    parser.add_argument("--aspect", default="1:1", choices=["1:1", "16:9", "9:16", "4:3", "3:4", "21:9"])
    parser.add_argument("--size", default="2K", choices=["1K", "2K"])
    parser.add_argument("--out", default=str(DEFAULT_OUT))
    parser.add_argument("--ref", action="append", default=[], metavar="PATH")
    parser.add_argument("--count", type=int, default=1)
    parser.add_argument("--no-open", action="store_true")
    args = parser.parse_args()

    if args.count < 1 or args.count > 8:
        die(2, "--count must be between 1 and 8")

    api_key = os.environ.get("OPENROUTER_API_KEY")
    if not api_key:
        die(2, "OPENROUTER_API_KEY env var not set. Run: export OPENROUTER_API_KEY='sk-or-v1-...'")

    out_dir = Path(args.out).expanduser()
    out_dir.mkdir(parents=True, exist_ok=True)
    ts = time.strftime("%Y%m%d-%H%M%S")
    slug = slugify(args.prompt)
    all_paths = []

    if args.count == 1:
        try:
            all_paths = generate_one(args.prompt, args.ref, args.aspect, args.size, api_key, out_dir, ts, slug, None)
        except RuntimeError as e:
            die(1, str(e))
    else:
        workers = min(args.count, MAX_PARALLEL)
        with ThreadPoolExecutor(max_workers=workers) as ex:
            futures = {
                ex.submit(generate_one, args.prompt, args.ref, args.aspect, args.size, api_key, out_dir, ts, slug, i + 1): i + 1
                for i in range(args.count)
            }
            for fut in as_completed(futures):
                try:
                    all_paths.extend(fut.result())
                except Exception as e:
                    print(f"flicky warn: variation {futures[fut]} failed: {e}", file=sys.stderr)

    if not all_paths:
        die(3, "no images saved")
    all_paths.sort()
    for p in all_paths:
        print(p)
    if not args.no_open:
        try:
            subprocess.run(["open"] + all_paths, check=False)
        except FileNotFoundError:
            pass


if __name__ == "__main__":
    main()
```
