Skill: chunhualiao/scrapbook-illustrator (version 1.3.0)

--- name: article-illustrator description: Insert scrapbook-style illustrations into articles using image generation (GLM or OpenRouter). Use when the user wants to add images to an article, create illustrated content, or requests "illustrate this article", "add images to this article", "generate pictures for this post", "insert illustrations". --- # Scrapbook-Style Illustration Inserter Transform articles into illustrated markdown by generating hand-crafted scrapbook-style images and inserting them at logical break points. > **Attribution:** Based on [glm-image](https://github.com/ViffyGwaanl/glm-image) by ViffyGwaanl (MIT License). ## Setup The bundled `scripts/generate.py` supports two providers. You need at least one: ### Option A: GLM (BigModel / Zhipu AI) — preferred for Chinese text 1. Register at https://bigmodel.cn 2. Set your key via any of: - `export GLM_API_KEY="your-key"` - Add `"glm_api_key": "your-key"` to `~/.openclaw/config.json` - Add `GLM_API_KEY=your-key` to `.env` in the skill directory ### Option B: OpenRouter — broader model selection, best for English 1. Register at https://openrouter.ai 2. Set your key via any of: - `export OPENROUTER_API_KEY="your-key"` - Add `"openrouter_api_key": "your-key"` to `~/.openclaw/config.json` - Add `OPENROUTER_API_KEY=your-key` to `.env` in the skill directory Default OpenRouter model: `openai/gpt-5-image-mini` (strong text rendering for scrapbook prompts). **Auto-detection:** If both keys are present, GLM is preferred. Use `--provider openrouter` or `--provider glm` to override. ## Inputs - **article**: The full article text (markdown or plain text) - **image_count** (optional): Number of images to generate, 2–5 (default: 3) - **orientation** (optional): `portrait` (1088x1920, default) or `landscape` (1920x1088) - **language** (required): Language for text in generated images — `zh`, `en`, `ja`, `ko`, `fr`, `de`, `es`. Always ask the user; never default or infer. ## Workflow ### Step 0: Check API Key Run a quick check — if neither `GLM_API_KEY` nor `OPENROUTER_API_KEY` is found, stop and tell the user how to configure (see Setup above). Do not proceed without a valid key. ### Step 1: Validate Input Before generating any images: - Confirm article is at least 200 words. If shorter, warn user: "Article may be too short for meaningful illustration — proceed anyway?" - Count major sections or topic transitions (H2 headings, paragraph breaks). This determines insertion points. - Ask the user which language to use for image text. Never infer from the article language. ### Step 2: Generate Image Prompts Read the scrapbook system prompt from `references/scrapbook-prompt.md`. **Language handling:** - Default (`zh`): generate image descriptions in Chinese as the scrapbook prompt produces - If `language=en` or other: after generating the JSON plan, translate all `title` and `description` fields to the target language before passing to generate.py. Keep the JSON structure identical. Using that prompt, analyze the article and output a JSON plan: ```json { "project_title": "Article title — Scrapbook Style", "style": "Physical Mixed-Media Scrapbook", "total_images": 3, "images": [ { "image_id": 1, "title": "Image caption", "description": "300-500 character visual description in scrapbook style...", "insert_after": "Exact sentence or heading after which to insert the image" } ] } ``` Target 1 image per 300–400 words of article content. ### Step 3: Generate All Images in Parallel **CRITICAL: Launch all image generations simultaneously.** Each generation takes 20–40 seconds. Sequential generation wastes the user's time. Use background processes or concurrent exec calls to run all images at once. For each image in the JSON plan, run the bundled script (all at the same time): ```bash # Launch ALL images concurrently — do NOT wait for one to finish before starting the next python3 scripts/generate.py "<description_1>" --language <lang> --size 1088x1920 & python3 scripts/generate.py "<description_2>" --language <lang> --size 1088x1920 & python3 scripts/generate.py "<description_3>" --language <lang> --size 1088x1920 & wait # Wait for all to complete ``` In an OpenClaw agent context, use concurrent `exec` tool calls (one per image) rather than sequential calls. The agent MUST issue all exec calls in a single tool-use turn so they run in parallel. Additional flags: - `--provider glm|openrouter` — override auto-detection - `--model <model>` — override OpenRouter model (default: `openai/gpt-5-image-mini`) The script automatically: - Appends a margin guard instruction (OpenRouter only) to prevent edge clipping - Displays cost per image at the end of each run **On failure:** If generation fails for one image, log the error and continue with remaining images. Do not abort the entire run. Note failed images in the output with `[Image generation failed: <error>]`. Collect: image URL/local path and cost for each successful image. ### Step 4: Compose Final Markdown Insert each image after its designated `insert_after` anchor: ```markdown ![Image caption](image_url) ``` If the `insert_after` anchor is not found verbatim, insert at the nearest paragraph break. ## Output Format Return the complete markdown article with: 1. All original article text preserved exactly 2. Generated images inserted at logical break points 3. Captions from the JSON plan 4. A summary line at the end: `<!-- Illustrated: N images generated, M failed, total cost: $X.XX -->` ## Agent Owner Executed by the main OpenClaw agent session. No sub-agents spawned. The agent reads the article, generates prompts, invokes generate.py for each, and composes output in a single session. ## Success Criteria Succeeds when: 1. Article validated (length check passed or user confirmed) 2. Image prompt JSON generated with 2–5 entries 3. At least one image successfully generated 4. Final markdown returned with images inserted ## Edge Cases - **Short article (<200 words)**: Warn and confirm before proceeding - **Generation failure**: Log error inline, continue with other images — do not abort - **Anchor not found**: Insert at nearest paragraph break instead - **No API key**: Surface error immediately with setup instructions - **All images fail**: Return original article unchanged with error summary ## Tips - Generate 2–5 images depending on article length - Insert images after major topic transitions (not mid-paragraph) - Keep descriptions faithful to the scrapbook style in `references/scrapbook-prompt.md` - Portrait orientation (1088x1920) works best for inline article images - OpenRouter with gpt-5-image-mini costs ~$0.045–0.050 per image - GLM costs ~¥0.10 (~$0.014) per standard image

path: CHANGELOG.md
content: # Changelog

## [1.3.0] - 2026-02-18
### Changed
- Step 3: image generation now runs in parallel (all images launched simultaneously)
- Explicit instruction for agents to use concurrent exec calls in a single tool-use turn
- Reduces total generation time from N×30s to ~30s regardless of image count

## [1.2.0] - 2026-02-18
### Added
- OpenRouter provider support (auto-detects from available keys, GLM preferred)
- `--provider` flag to override auto-detection
- `--model` flag for OpenRouter model selection (default: `openai/gpt-5-image-mini`)
- Margin guard for OpenRouter prompts — prevents edge clipping (center 80% constraint)
- Per-image cost display (GLM: ¥ pricing, OpenRouter: USD from usage.cost)
- 7 supported languages: zh, en, ja, ko, fr, de, es
- Setup section with dual-provider configuration docs
- Step 0: API key validation before proceeding
### Changed
- `generate.py` synced from glm-image v1.8.0 (372 lines, full feature parity)
- `language` parameter now required (was optional with zh default)
- SKILL.md rewritten to document both providers

## [1.1.0] - 2026-02-17
### Added
- Bundled `scripts/generate.py` from glm-image — no separate skill dependency needed
- `language` input parameter: `zh` (Chinese, default) or `en` (English) — controls text language in generated images
- skill.yml with display_name "Scrapbook-Style Illustration Inserter", attribution, triggers
- README.md
- Formal Inputs section (article, image_count, orientation)
- Step 1 input validation (200-word minimum check)
- glm-image failure handling (continue on single failure, abort only if all fail)
- Anchor-not-found fallback (nearest paragraph break)
- Edge cases section
- Agent Owner, Success Criteria, Configuration sections
- Attribution to ViffyGwaanl
### Fixed
- Removed hardcoded ~/.claude/skills/ path — now invokes glm-image skill by name
- Fixed step numbering mismatch (Workflow list aligned with Step N sections)

## [1.0.0] - 2026-02-01
### Added
- Initial release by ViffyGwaanl
- Scrapbook-style illustration workflow
- references/scrapbook-prompt.md system prompt


path: README.md
content: # Scrapbook-Style Illustration Inserter

Transform articles into richly illustrated markdown using **hand-crafted scrapbook-style images** — think torn paper edges, washi tape, hand-drawn arrows, layered textures, and high-saturation collage aesthetics generated via the [GLM-Image API](https://open.bigmodel.cn).

**Attribution:** Based on [glm-image](https://github.com/ViffyGwaanl/glm-image) by ViffyGwaanl (MIT License).

## What is Scrapbook Style?

Each generated image mimics a physical mixed-media scrapbook page:
- Torn and rough-edged paper layers
- Washi tape, paperclips, and push-pin textures
- Watercolor stains, newspaper clippings, grid paper backgrounds
- Hand-drawn marker circles, arrows, and decorative lines
- High-saturation, multi-color collisions

This style makes article illustrations feel handmade and visually distinctive — not stock photos.

## Prerequisites

- `GLM_API_KEY` set in TOOLS.md (get one at https://open.bigmodel.cn)
- Python 3 + `requests` library: `pip install requests`

No other skills required — GLM image generation is bundled.

## Usage

Provide an article and ask:
- "Illustrate this article"
- "Add images to my post"
- "Generate pictures for this content"
- "Illustrate in English" (for English text in images)

## Options

| Parameter | Default | Description |
|-----------|---------|-------------|
| `image_count` | 3 | Number of images to generate (2–5) |
| `orientation` | portrait | `portrait` (1088×1920) or `landscape` (1920×1088) |
| `language` | zh | `zh` (Chinese) or `en` (English) for text in images |

## How It Works

1. Validates article length (≥200 words recommended)
2. Generates scrapbook-style image descriptions as JSON using a specialized prompt
3. Creates each image via glm-image (failures logged inline, run continues)
4. Returns the full article with images inserted at logical break points

## Output

Complete markdown article with generated images embedded as `![caption](url)` links.

## License

MIT


path: STATUS.json
content: stage: test
version: 1.3.0
attribution: https://github.com/ViffyGwaanl/glm-image

path: references/scrapbook-prompt.md
content: # 手工剪贴簿风·商业科普图生成专家

## 角色设定

你是一位热衷于实体材质创作的手工剪贴簿艺术家。你擅长利用撕裂的彩纸、旧杂志、和纸胶带、涂鸦和现成物（如回形针、照片），将商业信息拼贴成一页页充满手工感和材质碰撞的竖版视觉笔记。

## 核心任务

接收商业分析文本，将其转化为一套"实体剪贴簿"风格的竖版长图描述，以JSON格式输出。

## 风格规范

**核心风格**：实体手工剪贴簿（Physical Mixed-Media Scrapbook）。每一层元素都必须有真实的物理质感，严禁平滑的数字化效果。

**关键材质要素**：
- **撕裂边缘**：所有纸张、标签、横幅的边缘必须是粗糙、撕裂露白的
- **固定方式**：半透明和纸胶带、透明胶带痕迹、回形针、图钉、长尾夹
- **背景纹理**：杂乱的彩色碎纸、水彩渍迹、旧报纸、电路板纹理、方格纸等叠加
- **手绘装饰**：马克笔手绘的圆圈、箭头、放射线、波浪线、星星

**配色与字体**：高饱和度多色碰撞。标题用粗壮无衬线黑体，标签带粗黑描边模拟剪报效果。

## 输出格式

输出为标准JSON，结构如下：

```json
{
  "project_title": "项目标题 - 手工剪贴簿风格系列图",
  "style": "实体手工剪贴簿（Physical Mixed-Media Scrapbook）",
  "total_images": 图片总数,
  "images": [
    {
      "image_id": 序号,
      "title": "图片标题",
      "description": "一段连续的画面描述文字...",
      "insert_after": "文章中应该插入此图的段落关键词或标题"
    }
  ]
}
```

## description字段写作规范

每个description是一段连续的描述性文字（300-500字），按以下顺序组织内容，不使用换行或结构化标记：

1. 开头说明画面规格（9:16竖版）和整体风格
2. 顶部横幅：纸张颜色、撕裂质感、固定方式、主标题内容、手绘装饰
3. 背景层：拼贴元素（照片碎片、水彩、纹理等）
4. 左侧主体：相框/卡片的边框风格、固定方式、大字标题、副标题、配图
5. 右侧细节：撕裂纸条、便利贴的颜色、内容、配套图标/插图
6. 底部通栏：撕裂纸带颜色、纹理、通栏标题、图标与文字

## 写作要点

- 具体说明每个元素的颜色、材质、位置关系
- 使用「」标注画面中出现的文字内容
- 用流畅中文描述，像在讲述一幅画面的构成

## 输出示例

```json
{
  "project_title": "GLM-Image技术深度解析 - 手工剪贴簿风格系列图",
  "style": "实体手工剪贴簿（Physical Mixed-Media Scrapbook）",
  "total_images": 2,
  "images": [
    {
      "image_id": 1,
      "title": "GLM-Image 开源：国产芯片训练出的图像生成 SOTA",
      "description": "一张9:16竖版手工剪贴簿风格的图像。顶部是一条亮红色粗糙撕裂边缘的纸质横幅，用半透明和纸胶带斜着固定，左上角夹着金色回形针，压着一小块写有「首发」的碎纸。横幅上用粗黑体手工剪报风写着主标题「GLM-Image 开源：国产芯片炼出图像生成 SOTA」，标题周围用黑色马克笔画着放射线和手绘画笔调色盘图标。背景是拼贴的AI生成图片碎片、芯片电路图纹理、水彩晕染和浅蓝色卡纸。左侧有一个带磨损金属边的数码相框，用透明胶带斜贴，相框内大字写着「自回归 + 扩散解码器」，副标题「9B 自回归理解指令 + 7B DiT 精绘细节」，背景是文字prompt气泡到精美图像的箭头连接图，边缘有手绘箭头标注「读懂指令」「写对文字」。右侧散落三张不同颜色的撕裂纸条便利贴，被和纸胶带交叉固定。配有芯片实物照片剪影加华为logo小贴纸、中文艺术字海报截图、多分辨率图像网格等插图。三个撕裂纸条标签带粗黑描边：「昇腾 A2 + 昇思 MindSpore：全程国产训练」「CVTG-2K & LongText-Bench：文字渲染开源第一」「384×384 到 2048×2048：任意比例原生支持」。旁边还有一条窄蓝色撕裂纸条写着「认知型生成：知识 + 推理新范式」，上面有马克笔波浪线和星星。底部是一整条深蓝色撕裂纸带，印着电路纹理，用和纸胶带固定。通栏大标题「从"画个图"到"懂你想要什么"的认知型生成引擎」，下方三个小图标配文字：芯片图标「首个国产芯片全程训练的 SOTA 模型」、毛笔图标「中英文字渲染开源 SOTA，汉字准确率 0.91+」、开源标志「开源权重，与社区共享前沿探索」。",
      "insert_after": "开篇介绍"
    },
    {
      "image_id": 2,
      "title": "架构揭秘：自回归 + 扩散解码器，各司其职",
      "description": "一张9:16竖版手工剪贴簿风格的图像。顶部是宽大的白色牛皮纸横幅，边缘撕裂露出纤维感，用条纹和圆点图案的和纸胶带斜着固定。主标题「一个读指令，一个画细节：混合架构的妙处」用粗黑体手工剪报风呈现，个别字稍微倾斜。周围手绘大脑图标代表理解、画笔图标代表绘制，用箭头连接。背景堆叠着神经网络架构图碎片、代码截图片段、浅紫与淡黄色水彩笔刷。左侧有一个深棕色木质边框的黑板相框，四角用彩色图钉钉住。相框内分上下两部分：上半蓝色区域大字「9B 自回归模型」，小字「语言模型底座 → 语义理解 + 全局构图」，配手绘大脑和prompt文字气泡；下半橙色区域大字「7B DiT 扩散解码器」，小字「Glyph Encoder → 高频细节 + 文字笔画」，配手绘画笔和放大镜看文字细节。中间用手绘粗箭头连接，箭头上写「协同工作」。右侧排布两张对比卡片。卡片一带红色叉号，展示生成图带乱码文字的截图，标签「以前：复杂指令理解 vs 精准文字，难以兼顾」。卡片二带绿色勾号，展示完美渲染中文的海报截图，标签「现在：自回归管"懂"，扩散管"画"，各司其职」。用粗黑马克笔画出从问题到解决方案的大箭头，上面写「架构创新」。底部是一条深紫色撕裂纸带，压着小齿轮图标贴纸。标题「读懂指令 × 写对文字 = 认知型生成的第一步」，细节栏：大脑图标「自回归：像读文章一样理解你的需求」、画笔图标「扩散解码：像工匠一样雕琢每个像素」、握手图标「混合架构：两个专家，一次完美配合」。",
      "insert_after": "架构介绍"
    }
  ]
}
```


path: scripts/generate.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
GLM-Image generation script for Claude Code skill.
Generates an image from a text prompt and outputs markdown-formatted result.

Supports two providers:
  - glm       : BigModel / Zhipu AI (requires GLM_API_KEY)
  - openrouter: OpenRouter image models (requires OPENROUTER_API_KEY)

Auto-detects provider from available keys unless --provider is specified.
"""

import os
import sys
import json
import base64
import datetime
import re
import argparse
import requests


SUPPORTED_LANGUAGES = {
    "zh": "Chinese",
    "en": "English",
    "ja": "Japanese",
    "ko": "Korean",
    "fr": "French",
    "de": "German",
    "es": "Spanish",
}

# Default OpenRouter image model. Override with --model.
# gemini-3-pro: strong image generation with good text rendering
OPENROUTER_DEFAULT_MODEL = "google/gemini-3-pro-image-preview"

# GLM-Image known pricing (source: BigModel API docs, as of Jan 2026)
GLM_PRICE_STANDARD_CNY = 0.10   # ¥ per image
GLM_PRICE_HD_CNY       = 0.20   # ¥ per image (HD quality)
CNY_TO_USD             = 0.138  # approximate; fluctuates


# ---------------------------------------------------------------------------
# Key loaders
# ---------------------------------------------------------------------------

def _load_key_from_env_and_configs(env_var: str, config_key: str) -> str | None:
    """Try env var → config files → .env files. Returns None if not found."""
    value = os.environ.get(env_var)
    if value:
        return value

    config_paths = [
        "config.json",
        os.path.expanduser("~/.openclaw/config.json"),
        os.path.expanduser("~/.claude/config.json"),
    ]
    for path in config_paths:
        if os.path.exists(path):
            try:
                with open(path) as f:
                    data = json.load(f)
                    if data.get(config_key):
                        return data[config_key]
            except Exception:
                pass

    script_dir = os.path.dirname(os.path.abspath(__file__))
    skill_dir = os.path.dirname(script_dir)
    env_paths = [
        os.path.join(skill_dir, ".env"),
        ".env",
        os.path.expanduser("~/.env"),
    ]
    for path in env_paths:
        if os.path.exists(path):
            try:
                with open(path) as f:
                    for line in f:
                        line = line.strip()
                        if line.startswith(f"{env_var}="):
                            val = line.split("=", 1)[1].strip().strip('"')
                            if val:
                                return val
            except Exception:
                pass

    return None


# ---------------------------------------------------------------------------
# Cost helpers
# ---------------------------------------------------------------------------

def fetch_openrouter_cost(generation_id: str, api_key: str) -> float | None:
    """
    Fallback: query OpenRouter's /api/v1/generation endpoint for cost in USD.
    Only used when cost wasn't in the main response's usage field.
    """
    try:
        url = f"https://openrouter.ai/api/v1/generation?id={generation_id}"
        headers = {"Authorization": f"Bearer {api_key}"}
        resp = requests.get(url, headers=headers, timeout=15)
        resp.raise_for_status()
        return resp.json().get("data", {}).get("total_cost")
    except Exception:
        return None


def glm_cost_str(quality: str) -> str:
    """Return formatted cost string for a GLM image generation."""
    cny = GLM_PRICE_HD_CNY if quality == "hd" else GLM_PRICE_STANDARD_CNY
    usd = cny * CNY_TO_USD
    return f"¥{cny:.2f} (~${usd:.4f}) — verify at https://open.bigmodel.cn/console"


def load_glm_key() -> str:
    key = _load_key_from_env_and_configs("GLM_API_KEY", "api_key")
    if not key:
        raise ValueError(
            "GLM_API_KEY not found.\n"
            "Get your key at https://open.bigmodel.cn → Console → API Keys\n"
            "Then set it via:\n"
            "  export GLM_API_KEY=your-key\n"
            "  or add to ~/.openclaw/config.json: {\"api_key\": \"your-key\"}\n"
            "  or add GLM_API_KEY=your-key to .env"
        )
    return key


def load_openrouter_key() -> str:
    key = _load_key_from_env_and_configs("OPENROUTER_API_KEY", "openrouter_api_key")
    if not key:
        raise ValueError(
            "OPENROUTER_API_KEY not found.\n"
            "Get your key at https://openrouter.ai → Keys\n"
            "Then set it via:\n"
            "  export OPENROUTER_API_KEY=your-key\n"
            "  or add to ~/.openclaw/config.json: {\"openrouter_api_key\": \"your-key\"}\n"
            "  or add OPENROUTER_API_KEY=your-key to .env"
        )
    return key


def detect_provider() -> str:
    """Return 'glm' or 'openrouter' based on which key is available."""
    if _load_key_from_env_and_configs("GLM_API_KEY", "api_key"):
        return "glm"
    if _load_key_from_env_and_configs("OPENROUTER_API_KEY", "openrouter_api_key"):
        return "openrouter"
    raise ValueError(
        "No API key found. Configure one of:\n"
        "  GLM_API_KEY       — from https://open.bigmodel.cn (GLM Image)\n"
        "  OPENROUTER_API_KEY — from https://openrouter.ai  (Gemini, GPT-5 Image, etc.)"
    )


# ---------------------------------------------------------------------------
# GLM provider
# ---------------------------------------------------------------------------

def generate_image_glm(
    prompt: str,
    size: str = "1088x1920",
    quality: str = "hd",
    watermark: bool = False,
    output_dir: str = "output",
) -> dict:
    [REDACTED_SECRET])
    url = "https://open.bigmodel.cn/api/paas/v4/images/generations"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}",
    }
    body = {
        "model": "glm-image",
        "prompt": prompt,
        "size": size,
        "quality": quality,
        "watermark_enabled": str(watermark).lower(),
    }

    response = requests.post(url, headers=headers, json=body, timeout=120)
    response.raise_for_status()
    data = response.json()

    if "data" not in data or not data["data"]:
        raise RuntimeError("No image returned from GLM API")

    image_url = data["data"][0]["url"]

    os.makedirs(output_dir, exist_ok=True)
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    safe_prompt = re.sub(r'[\\/:*?"<>|]', "", prompt).replace(" ", "_")[:30]
    filename = f"{timestamp}_{safe_prompt}.png"
    filepath = os.path.join(output_dir, filename)

    img_response = requests.get(image_url, timeout=60)
    img_response.raise_for_status()
    with open(filepath, "wb") as f:
        f.write(img_response.content)

    return {"url": image_url, "local_path": filepath, "prompt": prompt, "quality": quality}


# ---------------------------------------------------------------------------
# OpenRouter provider
# ---------------------------------------------------------------------------

def generate_image_openrouter(
    prompt: str,
    model: str = OPENROUTER_DEFAULT_MODEL,
    output_dir: str = "output",
) -> dict:
    [REDACTED_SECRET])
    url = "https://openrouter.ai/api/v1/chat/completions"
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
    }
    # Some models output both text+image, others image-only.
    # "image" modality is always required; include "text" for hybrid models.
    #
    # Margin guard: gpt-5-image-mini and similar models often place content at
    # the very edge of the canvas, causing visible clipping. Appending an explicit
    # instruction to leave inset padding reliably prevents this.
    MARGIN_GUARD = (
        " LAYOUT: Leave comfortable margins on all four sides so nothing is cut off or "
        "near the edge. Prefer a tall vertical layout for long content. The model may "
        "choose appropriate dimensions — vertical orientation is recommended but not mandatory."
    )
    padded_prompt = prompt + MARGIN_GUARD

    body = {
        "model": model,
        "messages": [{"role": "user", "content": padded_prompt}],
        "modalities": ["image", "text"],
        "stream": False,
    }

    response = requests.post(url, headers=headers, json=body, timeout=120)
    response.raise_for_status()
    data = response.json()

    choices = data.get("choices", [])
    if not choices:
        raise RuntimeError("No choices returned from OpenRouter")

    message = choices[0].get("message", {})
    images = message.get("images")
    if not images:
        raise RuntimeError(
            f"No image in OpenRouter response. Model '{model}' may not support image output.\n"
            f"Try: google/gemini-3-pro-image-preview or openai/gpt-5-image-mini"
        )

    generation_id = data.get("id")
    # Cost is available directly in usage.cost (USD)
    usage_cost = data.get("usage", {}).get("cost")

    image_data_url = images[0]["image_url"]["url"]  # "data:image/png;base64,..."

    os.makedirs(output_dir, exist_ok=True)
    timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
    safe_prompt = re.sub(r'[\\/:*?"<>|]', "", prompt).replace(" ", "_")[:30]
    filename = f"{timestamp}_{safe_prompt}.png"
    filepath = os.path.join(output_dir, filename)

    _, b64_data = image_data_url.split(",", 1)
    with open(filepath, "wb") as f:
        f.write(base64.b64decode(b64_data))

    return {
        "url": image_data_url,
        "local_path": filepath,
        "prompt": prompt,
        "generation_id": generation_id,
        "usage_cost": usage_cost,
    }


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def main():
    parser = argparse.ArgumentParser(description="Generate image from prompt")
    parser.add_argument("prompt", help="Image description")
    parser.add_argument(
        "--language",
        required=True,
        choices=list(SUPPORTED_LANGUAGES.keys()),
        help=(
            "Prompt language (REQUIRED). Supported: "
            + ", ".join(f"{k} ({v})" for k, v in SUPPORTED_LANGUAGES.items())
        ),
    )
    parser.add_argument(
        "--provider",
        choices=["glm", "openrouter"],
        default=None,
        help="API provider. Auto-detected from available keys if not specified.",
    )
    parser.add_argument(
        "--model",
        default=None,
        help=(
            f"Model to use (OpenRouter only). Default: {OPENROUTER_DEFAULT_MODEL}. "
            "See https://openrouter.ai/collections/image-models"
        ),
    )
    # GLM-only options
    parser.add_argument("--size", default="1088x1920", help="Image size, GLM only (default: 1088x1920)")
    parser.add_argument("--quality", default="hd", help="Image quality, GLM only (default: hd)")
    parser.add_argument("--output", default="output", help="Output directory (default: output)")
    parser.add_argument("--watermark", action="store_true", help="Enable watermark, GLM only")

    args = parser.parse_args()

    provider = args.provider or detect_provider()

    try:
        if provider == "glm":
            if args.model:
                print(f"Warning: --model is ignored for provider 'glm' (model is always glm-image)", file=sys.stderr)
            result = generate_image_glm(
                prompt=args.prompt,
                size=args.size,
                quality=args.quality,
                watermark=args.watermark,
                output_dir=args.output,
            )
        else:  # openrouter
            model = args.model or OPENROUTER_DEFAULT_MODEL
            result = generate_image_openrouter(
                prompt=args.prompt,
                model=model,
                output_dir=args.output,
            )

        lang_label = SUPPORTED_LANGUAGES.get(args.language, args.language)
        print(f"Provider: {provider}")
        print(f"Language: {lang_label} ({args.language})")
        print(f"Image saved: {result['local_path']}")
        print(f"\nMarkdown URL:\n![{result['prompt']}]({result['url']})")
        print(f"\nLocal path: {result['local_path']}")

        # Cost reporting
        if provider == "glm":
            print(f"\nCost: {glm_cost_str(result.get('quality', args.quality))}")
        else:
            cost = result.get("usage_cost")
            if cost is None:
                # fallback: query generation endpoint
                gen_id = result.get("generation_id")
                if gen_id:
                    cost = fetch_openrouter_cost(gen_id, load_openrouter_key())
            if cost is not None:
                print(f"\nCost: ${cost:.6f} USD")
            else:
                print(f"\nCost: unavailable — check https://openrouter.ai/activity")

    except Exception as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)


if __name__ == "__main__":
    main()


path: skill.yml
content: name: article-illustrator
display_name: "Scrapbook-Style Illustration Inserter"
version: 1.3.0
description: >
  Insert scrapbook-style illustrations into articles using GLM-Image or OpenRouter.
  Analyzes article sections, generates hand-crafted scrapbook image prompts, creates images,
  and returns the full article with images embedded. Supports GLM and OpenRouter providers
  with auto-detection, margin guard for edge clipping prevention, and per-image cost display.
attribution:
  source: https://github.com/ViffyGwaanl/glm-image
  author: ViffyGwaanl
  license: MIT
permissions:
  - exec
  - read
triggers:
  - illustrate this article
  - add images to this article
  - generate pictures for this post
  - illustrate my post
  - add illustrations
  - illustrate in english
  - illustrate in chinese


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