flicky

BY @GLO — 0 DOWNLOADS

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.

~/.claude/skills/flicky/SKILL.md

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:

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

Edit a reference image:

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

Combine multiple reference images:

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

Generate parallel variations:

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

Full options:

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:

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.

#!/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()

VERSIONS

  • 1.0.0 — 4.6 KB — 9f392ea44437

COMMENTS (0)

LOGIN TO COMMENT