Skill: alanhuangyoo/artclaw-creative-suite (version 1.0.0)

--- name: artclaw-creative-suite description: | ARTCLAW AI Creative Suite - invoke ARTCLAW platform's AI content creation capabilities via CLI. Supports AI image generation, video generation, workflow execution, multimodal analysis, and job management. All generation commands must run asynchronously using the current platform adapter. Requires an API Key prefixed with vk_ for authenticated features. Trigger keywords: generate image, generate video, AI painting, text-to-image, text-to-video, image-to-video, marketing image, logo, cover, workflow, video analysis, image analysis, ARTCLAW, ArtClaw. compatibility: dependencies: - ARTCLAW REST API (https://artclaw.com/api/v1) - Python 3.8+ with requests package metadata: { 'openclaw': { 'emoji': '🎨', 'requires': { 'env': ['ARTCLAW_API_KEY'] }, 'primaryEnv': 'ARTCLAW_API_KEY', }, } --- # ARTCLAW AI Creative Suite ARTCLAW is an all-in-one AI content creation platform. This skill uses `scripts/artclaw.py` as the single CLI entry point for authentication, submission, polling, retry, history, and JSON output. ## Mandatory Startup Flow 1. Detect the current agent platform. 2. Read exactly one matching adapter document from `docs/adapters/` before running generation or workflow commands. 3. Run the pre-flight API key check before authenticated operations. 4. Never mix execution rules from multiple adapters. If platform detection is ambiguous, ask the user which platform they are using. If the platform is unsupported, use `docs/adapters/others.md`. ## Platform Adapter Map | Platform | Adapter document | | --- | --- | | OpenClaw | `docs/adapters/openclaw.md` | | Claude Code | `docs/adapters/claude-code.md` | | Hermes Agent | `docs/adapters/hermes.md` | | Unknown / unsupported platform | `docs/adapters/others.md` | ## Universal Rules 1. Use the CLI, not raw curl: `python3 scripts/artclaw.py ...`. 2. Run `python3 scripts/artclaw.py verify-key` before authenticated operations. 3. Generation and workflow commands are long-running and must not block the main agent silently. 4. In Claude Code, prefer Bash `run_in_background: true` so `/tasks` can track the local background task. **DO NOT manually poll with `job-status` after the background task completes** — the background task already polls internally and returns the final result in its output. 5. In non-spawn platforms, use `--no-wait` by default unless the selected adapter explicitly defines a different async strategy or the user explicitly asks the agent to wait. 6. In OpenClaw-compatible spawn platforms, use `--spawn` instead of `--no-wait`. 7. Immediately tell the user after a generation/workflow job is submitted or a background task is started. 8. Analysis commands are synchronous and do not require spawn/background execution. 9. Guide users to https://artclaw.com/settings for API key creation and credit top-up. 10. Deliver generated media as native platform messages when the adapter supports it; otherwise return the result URL and job metadata. 11. Platform-specific async behavior, delivery semantics, and anti-blocking rules must be defined only in the selected adapter document under `docs/adapters/` and followed strictly. --- ## API Key & Account Run this before authenticated operations: ```bash python3 scripts/artclaw.py verify-key ``` - `{"status": "valid"}`: continue. - Missing, invalid, or revoked key: stop and guide the user to configure a key. Setup: 1. Open https://artclaw.com/settings. 2. Create an API key in the API Keys section. 3. Copy the generated key. It is prefixed with `vk_` and is shown only once. 4. Configure locally: ```bash python3 scripts/artclaw.py config-init --api-key "vk_xxx" ``` Useful account commands: ```bash python3 scripts/artclaw.py account-info python3 scripts/artclaw.py config ``` All local ARTCLAW data is stored under `~/.artclaw/`, including `config.json`, `last_job.json`, and `history/`. --- ## Generation Commands Generation and workflow commands are long-running. Always follow the current platform adapter before choosing `--spawn`, Claude Code `run_in_background`, `--no-wait`, or explicit waiting. Safe default: - OpenClaw-compatible adapters use `--spawn`. - Claude Code uses Bash `run_in_background: true` when available. - Other non-spawn adapters use `--no-wait` — unless the adapter defines its own background strategy (e.g. Hermes uses background terminal; see adapter doc). - Only omit both when the user explicitly asks the agent to wait for completion. ### Generate Image ```bash python3 scripts/artclaw.py generate-image \ --prompt "Cyberpunk cityscape at night, neon lights reflected in rainwater" \ --aspect-ratio 16:9 \ --no-wait ``` With references: ```bash python3 scripts/artclaw.py generate-image \ --prompt "Landscape painting in the same style" \ --reference-urls https://example.com/style_ref.jpg \ --no-wait ``` | Parameter | Description | Values | | --- | --- | --- | | `--prompt` | Image description, required | Text | | `--aspect-ratio` | Aspect ratio | `16:9`, `9:16`, `1:1`, `4:3`, `21:9` | | `--resolution` | Resolution | `1K`, `2K`, `4K` | | `--reference-urls` | Reference image URLs or base64 data URIs | One or more values | | `--reference-files` | Local reference files, auto-converted to base64 | One or more paths | | `--model` | Model override | Model ID | ### Generate Video ```bash python3 scripts/artclaw.py generate-video \ --prompt "Waves crashing on rocks, slow motion" \ --aspect-ratio 16:9 \ --duration 5 \ --resolution 720p \ --no-wait ``` Image-to-video: ```bash python3 scripts/artclaw.py generate-video \ --prompt "Make the person in the frame turn their head and smile" \ --reference-urls https://example.com/portrait.jpg \ --no-wait ``` | Parameter | Description | Values | | --- | --- | --- | | `--prompt` | Video description, required | Text | | `--aspect-ratio` | Aspect ratio | `16:9`, `9:16`, `1:1`, `4:3`, `21:9` | | `--duration` | Duration in seconds | `2` - `12` | | `--resolution` | Resolution | `480p`, `720p`, `1080p` | | `--reference-urls` | Reference image URLs or base64 data URIs | One or more values | | `--reference-files` | Local reference image files, auto-converted | One or more paths | | `--model` | Model override | Model ID | ### Generate Marketing Image ```bash python3 scripts/artclaw.py generate-marketing-image \ --prompt "Summer cool drinks promotional poster" \ --size 1080x1920 \ --no-wait ``` ### Execute Workflow ```bash python3 scripts/artclaw.py list-workflows ``` ```bash python3 scripts/artclaw.py run-workflow \ --workflow-id "text-to-image-basic" \ --inputs '{"prompt": "Anime-style forest"}' \ --no-wait ``` Replace `--no-wait` with `--spawn`, `--deliver-to`, and `--deliver-channel` only when the current platform adapter says to do so. --- ## Analysis Commands Analysis commands are synchronous. They do not require `--spawn` or background execution. ### Image Analysis ```bash python3 scripts/artclaw.py analyze-image \ --reference-urls https://example.com/photo.jpg \ --query "Describe the main content of this image" ``` ### Video Analysis ```bash python3 scripts/artclaw.py analyze-video \ --reference-urls https://example.com/clip.mp4 \ --query "Summarize the video content" ``` ### Script Extraction ```bash python3 scripts/artclaw.py analyze-script \ --reference-paths https://example.com/drama.mp4 ``` ### Character Profiles ```bash python3 scripts/artclaw.py analyze-characters \ --text "Li Ming is an introverted but brilliant programmer..." ``` --- ## Job Management & Errors ```bash python3 scripts/artclaw.py job-status --job-id "job_xxxxxxxx" python3 scripts/artclaw.py list-jobs --status success --limit 10 python3 scripts/artclaw.py cancel-job --job-id "job_xxxxxxxx" python3 scripts/artclaw.py last-job python3 scripts/artclaw.py history --limit 50 ``` Use `job-status`, `last-job`, and `history` for follow-up instead of resubmitting generation requests. There is no `latest-job` command. | Error | Cause | Resolution | | --- | --- | --- | | `401 Unauthorized` | API key invalid, missing, or revoked | Guide user to regenerate the key | | `402` / insufficient credits | Account balance depleted | Guide user to top up at https://artclaw.com/settings | | `404 Job not found` | Job ID does not exist or expired after 24h | Tell user the job expired and ask whether to regenerate | | `404 Workflow not found` | Workflow does not exist | Run `list-workflows` first | | `429 Too Many Requests` | Rate limit exceeded | Wait and retry | --- ## Delivery Targets Use delivery options only when the platform adapter supports spawn/delivery mode. `--spawn` must be paired with both `--deliver-to` and `--deliver-channel`. | Scenario | `--deliver-channel` | `--deliver-to` value | Source | | --- | --- | --- | --- | | Feishu group chat | `feishu` | `oc_xxx` chat ID | `conversation_label` or `chat_id`, strip `chat:` prefix | | Feishu direct message | `feishu` | `ou_xxx` open ID | `sender_id`, strip `user:` prefix | | Telegram | `telegram` | `chat_id` | Inbound message context | | Discord | `discord` | `channel_id` | Inbound message context | For Feishu, check `is_group_chat` in inbound metadata: `true` → use `oc_` chat ID; `false` → use `ou_` open ID. | Channel | Credential source | | --- | --- | | `feishu` | `~/.openclaw/openclaw.json` → `channels.feishu.accounts.main` | | `telegram` | `TELEGRAM_BOT_TOKEN` environment variable | | `discord` | Framework built-in message tool | --- ## Self Update ```bash python3 scripts/artclaw.py self-update ``` Preview without writing files: ```bash python3 scripts/artclaw.py self-update --dry-run ``` Downloads `https://github.com/ArtClaw1/artclaw-skill/archive/refs/heads/main.zip`, atomically applies added or modified files, and reports a JSON summary. Does not delete local-only files.

path: __init__.py
content: """ARTCLAW AI Creative Suite — Skill Package

This package provides AI content creation capabilities via the ARTCLAW REST API,
including image generation, video generation, workflow execution,
multimodal analysis, and prompt enhancement.

REST API Base: https://artclaw.com/api/v1
Auth: API Key (starts with vk_) passed as `X-API-KEY` HTTP header
Get your key: https://artclaw.com/settings
"""

SKILL_NAME = "artclaw-creative-suite"
SKILL_VERSION = "1.1.0"
API_BASE_URL = "https://artclaw.com/api/v1"

API_CONFIG = {
    "base_url": API_BASE_URL,
    "auth_header": "X-API-KEY",
}

ENDPOINTS = {
    # Generation (async, returns job_id)
    "generate_image": ("POST", "/generate/image"),
    "generate_video": ("POST", "/generate/video"),
    "generate_marketing_image": ("POST", "/generate/marketing-image"),
    # Workflows
    "list_workflows": ("GET", "/workflows/"),
    "run_workflow": ("POST", "/workflows/{workflow_id}/run"),
    # Analysis (synchronous)
    "analyze_image": ("POST", "/analyze/image"),
    "analyze_video": ("POST", "/analyze/video"),
    "analyze_script": ("POST", "/analyze/script"),
    "analyze_characters": ("POST", "/analyze/characters"),
    # Job management
    "get_job": ("GET", "/jobs/{job_id}"),
    "list_jobs": ("GET", "/jobs/"),
    "cancel_job": ("POST", "/jobs/{job_id}/cancel"),
    # Account
    "account_info": ("GET", "/account/info"),
    # Auth
    "verify_key": ("POST", "/auth/verify"),
}


def get_api_config() -> dict:
    """Return the REST API configuration for this skill."""
    return API_CONFIG


path: README.md
content: # 🎨 ARTCLAW AI Creative Suite — OpenClaw Skill

An all-in-one AI content creation skill powered by the [ARTCLAW](https://artclaw.com/) platform. It supports AI image generation, AI video generation, PPT creation, multimodal analysis, and workflow orchestration.

---

## ✨ Features

| Capability | Description |
|------|------|
| 🖼️ AI Image Generation | Text-to-image, image-to-image, marketing visuals, product image sets |
| 🎬 AI Video Generation | Text-to-video, image-to-video |
| 🔍 Multimodal Analysis | Image understanding, video analysis, script analysis, character profile extraction |
| ⚡ Workflows | Run preset pipelines in one click (animation / comics / e-commerce detail page, etc.) |
| ✏️ Prompt Tools | Prompt optimization for logos, covers, marketing images, and carousels (free, no API key required) |

---

## 📦 Install on OpenClaw

Tell OpenClaw directly:

```text
install skill: https://github.com/ArtClaw1/artclaw-skill
```

---

## 🔑 Configure API Key

Generation features in this skill require an ARTCLAW API key.

1. Open the ARTCLAW settings page: **https://artclaw.com/#/settings**
2. In the **API Keys** section, click "Create", enter a name, and copy the generated key (it starts with `vk_`)
3. In the OpenClaw Skill configuration page, set the environment variable:

   ```
   ARTCLAW_API_KEY=vk_your_key_here
   ```

> **Note:** Prompt tool features are free to use and do not require an API key.

---

## 🚀 Quick Start

After installation and configuration, describe your creative request directly in the OpenClaw chat:

```text
Help me create a cyberpunk-style cat illustration
```

```text
Use this product image to generate an e-commerce carousel set
```

---

## 📋 Version

- **Version:** 1.1.0
- **Author:** ARTCLAW Team
- **License:** MIT


path: manifest.json
content: {
  "name": "artclaw-creative-suite",
  "version": "1.1.0",
  "displayName": "ARTCLAW AI Creative Suite",
  "description": "Invoke ARTCLAW platform's AI content creation capabilities via REST API. Supports AI image generation, video generation, workflow execution, multimodal analysis, and more.",
  "author": {
    "name": "ARTCLAW Team",
    "url": "https://artclaw.com/"
  },
  "license": "MIT",
  "keywords": [
    "ai",
    "image-generation",
    "video-generation",
    "rest-api",
    "artclaw",
    "marketing",
    "creative",
    "text-to-image",
    "text-to-video",
    "ai-painting"
  ],
  "skill": "SKILL.md",
  "main": "__init__.py",
  "scripts": {
    "feishu_send_video": {
      "path": "scripts/feishu_send_video.py",
      "description": "Send video/image messages via Feishu (upload file → get file_key → send media message)",
      "runtime": "python3",
      "dependencies": [
        "requests"
      ],
      "credentials": "~/.openclaw/openclaw.json → channels.feishu"
    },
    "telegram_send_video": {
      "path": "scripts/telegram_send_video.py",
      "description": "Send video messages via Telegram Bot API (sendVideo, supports thumbnail and caption)",
      "runtime": "python3",
      "dependencies": [
        "requests"
      ],
      "credentials": "TELEGRAM_BOT_TOKEN environment variable"
    },
    "artclaw": {
      "path": "scripts/artclaw.py",
      "description": "ArtClaw REST API CLI client — supports all API endpoints (generation/analysis/workflow/prompt), designed for async Agent invocation. 23 subcommands, JSON output, supports --dry-run and spawn-task.",
      "runtime": "python3",
      "dependencies": [
        "requests"
      ],
      "credentials": "ARTCLAW_API_KEY environment variable"
    }
  },
  "api": {
    "baseUrl": "https://artclaw.com/api/v1",
    "transport": "http",
    "endpoints": {
      "generate": {
        "image": {
          "method": "POST",
          "path": "/generate/image"
        },
        "video": {
          "method": "POST",
          "path": "/generate/video"
        },
        "marketing-image": {
          "method": "POST",
          "path": "/generate/marketing-image"
        }
      },
      "analyze": {
        "image": {
          "method": "POST",
          "path": "/analyze/image"
        },
        "video": {
          "method": "POST",
          "path": "/analyze/video"
        },
        "script": {
          "method": "POST",
          "path": "/analyze/script"
        },
        "characters": {
          "method": "POST",
          "path": "/analyze/characters"
        }
      },
      "workflows": {
        "list": {
          "method": "GET",
          "path": "/workflows/"
        },
        "run": {
          "method": "POST",
          "path": "/workflows/{workflow_id}/run"
        }
      },
      "jobs": {
        "get": {
          "method": "GET",
          "path": "/jobs/{job_id}"
        },
        "list": {
          "method": "GET",
          "path": "/jobs/"
        },
        "cancel": {
          "method": "POST",
          "path": "/jobs/{job_id}/cancel"
        }
      },
      "account": {
        "info": {
          "method": "GET",
          "path": "/account/info"
        }
      },
      "auth": {
        "verify": {
          "method": "POST",
          "path": "/auth/verify"
        }
      }
    }
  },
  "auth": {
    "type": "apiKey",
    "headerName": "X-API-KEY",
    "paramLocation": "header",
    "prefix": "vk_",
    "required": true,
    "helpUrl": "https://artclaw.com/settings",
    "helpText": "Create an API Key in ARTCLAW Settings > API Keys. The key starts with vk_",
    "publicPaths": [
      "/api/v1/auth/verify"
    ]
  },
  "metadata": {
    "openclaw": {
      "emoji": "🎨",
      "requires": {
        "env": [
          "ARTCLAW_API_KEY"
        ]
      },
      "primaryEnv": "ARTCLAW_API_KEY",
      "envMapping": {
        "ARTCLAW_API_KEY": "X-API-KEY"
      }
    }
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/ArtClaw1/artclaw-skill.git",
    "branch": "main"
  },
  "update": {
    "method": "tarball",
    "url": "https://github.com/ArtClaw1/artclaw-skill/archive/refs/heads/main.tar.gz"
  },
  "install": {
    "steps": [
      "Create an API Key at https://artclaw.com/settings (starts with vk_)",
      "Add X-[REDACTED_SECRET] to the HTTP request header",
      "Call REST API endpoints at https://artclaw.com/api/v1/*"
    ]
  }
}

path: scripts/telegram_send_video.py
content: #!/usr/bin/env python3
"""
Send video messages via Telegram Bot API.

Usage:
    TELEGRAM_BOT_TOKEN=<token> python3 telegram_send_video.py \
        --video /path/to/video.mp4 \
        --to <chat_id> \
        [--cover-url https://...] \
        [--duration <seconds>] \
        [--caption "Video ready!"]

Security note:
    Bot token MUST be provided via TELEGRAM_BOT_TOKEN env var.
    Never pass it as a CLI argument (visible in `ps aux`).

Flow:
    1. Send video via sendVideo (supports thumbnail + caption)
    2. Output JSON with message_id on success

Telegram Bot API reference:
    - sendVideo: POST /bot<token>/sendVideo
"""

import argparse
import json
import os
import sys

try:
    import requests
except ImportError:
    print(json.dumps({
        "error": "requests package not installed",
        "install_command": "pip install requests",
    }), file=sys.stderr)
    sys.exit(1)

TELEGRAM_API_BASE = "https://api.telegram.org"
CONNECT_TIMEOUT = 15   # seconds
UPLOAD_TIMEOUT = 180   # seconds -- video upload can be slow


def get_bot_token() -> str:
    """Read bot token from environment only (never CLI args)."""
    [REDACTED_SECRET]"TELEGRAM_BOT_TOKEN", "").strip()
    if not [REDACTED_SECRET]
            "error": "TELEGRAM_BOT_TOKEN environment variable is not set",
            "hint": "Export TELEGRAM_BOT_TOKEN before running this script",
        }), file=sys.stderr)
        sys.exit(1)
    return token


def send_video(token: str, chat_id: str, video_path: str,
               cover_url: str = "", duration_seconds: int = 0,
               caption: str = "") -> dict:
    """
    Send a video file to a Telegram chat.

    Args:
        token: Bot token (from env, never logged)
        chat_id: Telegram chat_id (int or string, e.g. -1001234567890)
        video_path: Local path to the .mp4 file
        cover_url: Optional thumbnail URL (downloaded and sent as thumbnail)
        duration_seconds: Video duration in seconds (shown in player)
        caption: Optional caption text

    Returns:
        Telegram sendVideo result dict
    """
    print(f"[telegram] Sending video: {video_path}", file=sys.stderr)

    data: dict = {"chat_id": chat_id}
    if duration_seconds:
        data["duration"] = duration_seconds
    if caption:
        data["caption"] = caption
    data["supports_streaming"] = True

    files: dict = {}

    with open(video_path, "rb") as vf:
        files["video"] = (os.path.basename(video_path), vf, "video/mp4")

        # Download and attach thumbnail if provided
        thumb_bytes = None
        if cover_url:
            try:
                print(f"[telegram] Downloading thumbnail: {cover_url}", file=sys.stderr)
                r = requests.get(cover_url, timeout=(CONNECT_TIMEOUT, 30))
                if r.status_code == 200:
                    thumb_bytes = r.content
                    files["thumbnail"] = ("cover.jpg", thumb_bytes, "image/jpeg")
                else:
                    print(f"[telegram] Thumbnail download failed (HTTP {r.status_code}), skipping",
                          file=sys.stderr)
            except Exception as exc:
                print(f"[telegram] Thumbnail download error: {exc}, skipping", file=sys.stderr)

        resp = requests.post(
            f"{TELEGRAM_API_BASE}/bot{token}/sendVideo",
            data=data,
            files=files,
            timeout=(CONNECT_TIMEOUT, UPLOAD_TIMEOUT),
        )

    if resp.status_code != 200:
        body = {}
        try:
            body = resp.json()
        except Exception:
            pass
        print(json.dumps({
            "error": f"Telegram API error: HTTP {resp.status_code}",
            "description": body.get("description", resp.text[:200]),
        }), file=sys.stderr)
        sys.exit(1)

    result = resp.json()
    if not result.get("ok"):
        print(json.dumps({
            "error": "Telegram sendVideo failed",
            "description": result.get("description", "unknown error"),
        }), file=sys.stderr)
        sys.exit(1)

    msg = result["result"]
    message_id = msg.get("message_id")
    print(f"[telegram] Sent successfully, message_id={message_id}", file=sys.stderr)
    return msg


def main():
    parser = argparse.ArgumentParser(
        description="Send video via Telegram Bot API. "
                    "Bot token must be set via TELEGRAM_BOT_TOKEN env var."
    )
    parser.add_argument("--video", required=True,
                        help="Local video file path (.mp4)")
    parser.add_argument("--to", required=True,
                        help="Telegram chat_id (e.g. -1001234567890 or @channel)")
    parser.add_argument("--cover-url", default="",
                        help="Thumbnail image URL (optional)")
    parser.add_argument("--duration", type=int, default=0,
                        help="Video duration in seconds (optional)")
    parser.add_argument("--caption", default="",
                        help="Caption text (optional)")
    args = parser.parse_args()

    if not os.path.isfile(args.video):
        print(json.dumps({"error": f"Video file not found: {args.video}"}),
              file=sys.stderr)
        sys.exit(1)

    [REDACTED_SECRET])
    msg = send_video(
        token=token,
        chat_id=args.to,
        video_path=args.video,
        cover_url=args.cover_url,
        duration_seconds=args.duration,
        caption=args.caption,
    )

    print(json.dumps({
        "status": "ok",
        "message_id": msg.get("message_id"),
        "chat_id": msg.get("chat", {}).get("id"),
    }))


if __name__ == "__main__":
    main()


path: scripts/feishu_send_video.py
content: #!/usr/bin/env python3
"""
Send video messages via Feishu (Lark)

Usage:
    python3 feishu_send_video.py \
        --video /path/to/video.mp4 \
        --to ou_xxx \
        [--cover /path/to/cover.jpg | --cover-url https://...] \
        [--duration 20875]

Flow:
    1. Upload video file (file_type=mp4, with duration) -> get file_key
    2. Upload cover image (image_type=message) -> get image_key (optional)
    3. Send media message (msg_type=media)

Key points:
    - msg_type must be "media" (not "video" or "file")
    - file_type is "mp4" when uploading video
    - duration is in milliseconds; shows 00:00 if omitted
    - Cover image uploaded via im/v1/images for image_key, included in content
    - Sending without cover works but shows a black background with no preview

Feishu API reference:
    - Upload file: POST /open-apis/im/v1/files
    - Upload image: POST /open-apis/im/v1/images
    - Send message: POST /open-apis/im/v1/messages
"""

import argparse
import json
import os
import requests
import sys
import tempfile


def get_feishu_credentials():
    """Read Feishu credentials from openclaw config."""
    config_path = os.path.expanduser("~/.openclaw/openclaw.json")
    with open(config_path) as f:
        config = json.load(f)
    feishu = config.get("channels", {}).get("feishu", {})
    accounts = feishu.get("accounts", {})
    # Try accounts.main first, then top-level
    if accounts:
        main_acct = accounts.get("main", accounts.get("default", list(accounts.values())[0] if accounts else {}))
        app_id = main_acct.get("appId", "")
        app_secret = main_acct.get("appSecret", "")
    else:
        app_id = feishu.get("appId", "")
        app_secret = feishu.get("appSecret", "")
    if not app_id or not app_secret:
        print(json.dumps({
            "error": "Feishu credentials not found in ~/.openclaw/openclaw.json",
            "hint": "Configure channels.feishu.accounts.main.appId and appSecret"
        }), file=sys.stderr)
        sys.exit(1)
    return app_id, app_secret


def get_tenant_token():
    """Get tenant access token from Feishu."""
    app_id, app_secret = get_feishu_credentials()
    resp = requests.post(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        json={"app_id": app_id, "app_secret": app_secret},
    )
    resp.raise_for_status()
    return resp.json()["tenant_access_token"]


def upload_video(token, video_path, duration_ms=None):
    """Upload video file, return file_key."""
    data = {"file_type": "mp4", "file_name": os.path.basename(video_path)}
    if duration_ms:
        data["duration"] = str(int(duration_ms))

    with open(video_path, "rb") as f:
        resp = requests.post(
            "https://open.feishu.cn/open-apis/im/v1/files",
            headers={"Authorization": f"Bearer {token}"},
            data=data,
            files={"file": (os.path.basename(video_path), f, "video/mp4")},
            timeout=(15, 120),
        )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Upload failed: {result}")
    return result["data"]["file_key"]


def upload_cover(token, cover_source):
    """Upload cover image, return image_key. cover_source can be a local path or URL."""
    if cover_source.startswith("http"):
        # Download from URL
        img_resp = requests.get(cover_source, timeout=30)
        img_resp.raise_for_status()
        img_data = img_resp.content
        filename = "cover.jpg"
    else:
        with open(cover_source, "rb") as f:
            img_data = f.read()
        filename = os.path.basename(cover_source)

    resp = requests.post(
        "https://open.feishu.cn/open-apis/im/v1/images",
        headers={"Authorization": f"Bearer {token}"},
        data={"image_type": "message"},
        files={"image": (filename, img_data, "image/jpeg")},
        timeout=(15, 60),
    )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Cover upload failed: {result}")
    return result["data"]["image_key"]


def send_media_message(token, to, file_key, image_key=None):
    """Send media message via Feishu."""
    content = {"file_key": file_key}
    if image_key:
        content["image_key"] = image_key

    # Strip common prefixes from OpenClaw inbound metadata (e.g. "chat:oc_xxx" -> "oc_xxx")
    if to.startswith("chat:"):
        to = to[len("chat:"):]
    elif to.startswith("user:"):
        to = to[len("user:"):]

    # Determine receive_id_type based on prefix
    if to.startswith("oc_"):
        receive_id_type = "chat_id"
    else:
        receive_id_type = "open_id"

    resp = requests.post(
        f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json={
            "receive_id": to,
            "msg_type": "media",
            "content": json.dumps(content),
        },
        timeout=(15, 30),
    )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Send failed: {result}")
    return result["data"]["message_id"]


def send_image_message(token, to, image_source):
    """Send an image message via Feishu. image_source can be a local path or URL."""
    # Download or read image data
    if image_source.startswith("http"):
        img_resp = requests.get(image_source, timeout=30)
        img_resp.raise_for_status()
        img_data = img_resp.content
        filename = "image.jpg"
    else:
        with open(image_source, "rb") as f:
            img_data = f.read()
        filename = os.path.basename(image_source)

    # Upload image to get image_key
    resp = requests.post(
        "https://open.feishu.cn/open-apis/im/v1/images",
        headers={"Authorization": f"Bearer {token}"},
        data={"image_type": "message"},
        files={"image": (filename, img_data, "image/jpeg")},
        timeout=(15, 60),
    )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Image upload failed: {result}")
    image_key = result["data"]["image_key"]

    # Send image message
    content = {"image_key": image_key}

    # Strip common prefixes from OpenClaw inbound metadata (e.g. "chat:oc_xxx" -> "oc_xxx")
    if to.startswith("chat:"):
        to = to[len("chat:"):]
    elif to.startswith("user:"):
        to = to[len("user:"):]

    # Determine receive_id_type based on prefix
    if to.startswith("oc_"):
        receive_id_type = "chat_id"
    else:
        receive_id_type = "open_id"

    resp = requests.post(
        f"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type={receive_id_type}",
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
        json={
            "receive_id": to,
            "msg_type": "image",
            "content": json.dumps(content),
        },
        timeout=(15, 30),
    )
    resp.raise_for_status()
    result = resp.json()
    if result.get("code") != 0:
        raise Exception(f"Send image failed: {result}")
    return result["data"]["message_id"]


def _download_to_temp(url, suffix):
    """Download URL to a temporary file and return file path."""
    resp = requests.get(url, timeout=(15, 120))
    resp.raise_for_status()
    fd, tmp_path = tempfile.mkstemp(prefix="artclaw_", suffix=suffix)
    with os.fdopen(fd, "wb") as f:
        f.write(resp.content)
    return tmp_path


def main():
    parser = argparse.ArgumentParser(
        description="Send video or image messages via Feishu. "
                    "Use --video for videos, --image for images."
    )
    parser.add_argument("--video", help="Video file path")
    parser.add_argument("--video-url", help="Video URL (will be downloaded temporarily)")
    parser.add_argument("--image", help="Image file path or URL")
    parser.add_argument("--to", required=True, help="Recipient open_id (ou_xxx) or group chat_id (oc_xxx)")
    parser.add_argument("--cover", help="Cover image path or URL (for video)")
    parser.add_argument("--cover-url", help="Cover image URL (alias for --cover, for video)")
    parser.add_argument("--duration", type=int, help="Video duration in milliseconds")
    args = parser.parse_args()

    if not args.video and not args.video_url and not args.image:
        print(json.dumps({
            "error": "Must provide one of --video, --video-url, or --image"
        }), file=sys.stderr)
        sys.exit(1)
    if args.image and (args.video or args.video_url):
        print(json.dumps({
            "error": "Cannot specify --image together with video input"
        }), file=sys.stderr)
        sys.exit(1)
    if args.video and args.video_url:
        print(json.dumps({
            "error": "Cannot specify both --video and --video-url"
        }), file=sys.stderr)
        sys.exit(1)

    [REDACTED_SECRET])

    if args.image:
        print(f"[feishu] Sending image: {args.image}", file=sys.stderr)
        msg_id = send_image_message(token, args.to, args.image)
        print(json.dumps({
            "status": "ok",
            "type": "image",
            "message_id": msg_id,
        }))
        return

    # Video path
    temp_video_path = None
    if args.video_url:
        print(f"[feishu] Downloading video URL: {args.video_url}", file=sys.stderr)
        temp_video_path = _download_to_temp(args.video_url, ".mp4")
        video_path = temp_video_path
    else:
        video_path = args.video
    cover = args.cover or args.cover_url

    try:
        print(f"[feishu] Uploading video: {video_path}", file=sys.stderr)
        file_key = upload_video(token, video_path, args.duration)
        print(f"[feishu] Video file_key: {file_key}", file=sys.stderr)

        image_key = None
        if cover:
            print(f"[feishu] Uploading cover: {cover}", file=sys.stderr)
            image_key = upload_cover(token, cover)
            print(f"[feishu] Cover image_key: {image_key}", file=sys.stderr)

        msg_id = send_media_message(token, args.to, file_key, image_key)
        print(json.dumps({
            "status": "ok",
            "type": "video",
            "message_id": msg_id,
            "file_key": file_key,
            "image_key": image_key,
        }))
    finally:
        if temp_video_path and os.path.exists(temp_video_path):
            os.remove(temp_video_path)


if __name__ == "__main__":
    main()


path: scripts/artclaw.py
content: #!/usr/bin/env python3
"""
ArtClaw AI Creative Suite CLI — Agent-friendly REST API client.

Flow:  Submit Job → Poll Status → Return Result (JSON stdout)
API:   https://artclaw.com/api/v1
Auth:  X-API-KEY header (starts with vk_)

Requires:
  - pip install requests
  - ARTCLAW_API_KEY env var or config-init

All results output to stdout as JSON. Progress/diagnostics go to stderr.
"""

import argparse
import base64
import json
import os
import sys
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional

try:
    import requests
except ImportError:
    print(json.dumps({
        "error": "requests package not installed",
        "install_command": "pip install requests",
    }), file=sys.stderr)
    sys.exit(1)


# ---------------------------------------------------------------------------
# Section 1: Module-Level Constants
# ---------------------------------------------------------------------------

# Paths
STATE_DIR = Path(os.path.expanduser("~/.artclaw"))
CONFIG_FILE = STATE_DIR / "config.json"
LAST_JOB_FILE = STATE_DIR / "last_job.json"
HISTORY_DIR = STATE_DIR / "history"

# API
DEFAULT_BASE_URL = "https://artclaw.com/api/v1"
TOPUP_URL = "https://artclaw.com/settings"
GET_KEY_URL = "https://artclaw.com/settings"

# Self-update
GITHUB_REPO_URL = "https://github.com/ArtClaw1/artclaw-skill"
GITHUB_RAW_SCRIPT_URL = (
    "https://raw.githubusercontent.com/ArtClaw1/artclaw-skill/main/scripts/artclaw.py"
)
GITHUB_ARCHIVE_URL = (
    "https://github.com/ArtClaw1/artclaw-skill/archive/refs/heads/main.zip"
)

# Network
CONNECT_TIMEOUT = 15   # seconds — TCP handshake
READ_TIMEOUT = 60      # seconds — wait for response body
MAX_RETRIES = 2

# Polling profiles per job type
POLL_PROFILES: Dict[str, dict] = {
    "image":    {"interval": 5,  "timeout": 300,  "backoff": 1.5, "max_interval": 15},
    "video":    {"interval": 10, "timeout": 600,  "backoff": 1.5, "max_interval": 30},
    "workflow": {"interval": 30, "timeout": 1800, "backoff": 1.2, "max_interval": 60},
}

# Job type inference from subcommand
JOB_TYPE_MAP = {
    "generate-image": "image",
    "generate-video": "video",
    "generate-marketing-image": "image",
    "run-workflow": "workflow",
}

# Validation sets (module-level, not created per call)
VALID_ASPECT_RATIOS = frozenset({"16:9", "9:16", "1:1", "4:3", "21:9"})
VALID_RESOLUTIONS_IMAGE = frozenset({"1K", "2K", "4K"})
VALID_RESOLUTIONS_VIDEO = frozenset({"480p", "720p", "1080p"})
TERMINAL_STATUSES = frozenset({"success", "failed", "canceled", "expired"})
ALLOWED_URL_SCHEMES = ("http://", "https://")
ALLOWED_IMAGE_DATA_URI_PREFIX = "data:image/"

# Spawn
SPAWN_TIMEOUT_SECONDS = 2400  # 40 minutes


# ---------------------------------------------------------------------------
# Section 2: Exception Hierarchy
# ---------------------------------------------------------------------------

class ArtClawApiError(Exception):
    """Raised when an ArtClaw API call fails."""

    def __init__(self, message: str, *, status_code: int = 0,
                 code: str = "", detail: Any = None):
        super().__init__(message)
        self.status_code = status_code
        self.code = code
        self.detail = detail

    def is_retryable(self) -> bool:
        if self.status_code >= 500:
            return True
        if self.status_code == 429:
            return True
        if self.code == "network_error":
            return True
        return False

    def to_dict(self) -> dict:
        d: Dict[str, Any] = {"error": str(self)}
        if self.code:
            d["code"] = self.code
        if self.status_code:
            d["http_status"] = self.status_code
        if self.detail:
            d["detail"] = self.detail
        if self.status_code == 401:
            d["hint"] = f"API key invalid or missing. Get one at {GET_KEY_URL}"
        elif self.status_code == 402:
            d["hint"] = f"Insufficient credits. Top up at {TOPUP_URL}"
        elif self.status_code == 429:
            d["hint"] = "Rate limited (120 req/min). Wait a moment and retry."
        return d


class ArtClawPollTimeout(Exception):
    """Raised when polling exceeds the timeout for a job type."""

    def __init__(self, job_id: str, job_type: str, attempts: int,
                 elapsed: float, last_status: str = ""):
        msg = (f"Polling {job_type} job {job_id} timed out after "
               f"{attempts} attempts ({elapsed:.0f}s), last status: {last_status}")
        super().__init__(msg)
        self.job_id = job_id
        self.job_type = job_type
        self.attempts = attempts
        self.elapsed = elapsed
        self.last_status = last_status


class ArtClawPollFailed(Exception):
    """Raised when a polled job enters a terminal failure state."""

    def __init__(self, job_id: str, status: str, error_detail: Any = None):
        msg = f"Job {job_id} entered terminal failure: {status}"
        super().__init__(msg)
        self.job_id = job_id
        self.status = status
        self.error_detail = error_detail


# ---------------------------------------------------------------------------
# Section 3: Config / Credential Loading
# ---------------------------------------------------------------------------

def _file_to_base64(file_path: str) -> str:
    """Convert local image file to base64 data URI."""
    path = Path(file_path)
    if not path.exists():
        raise FileNotFoundError(f"Reference file not found: {file_path}")

    # Determine MIME type from extension
    ext = path.suffix.lower()
    mime_map = {
        ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
        ".png": "image/png", ".gif": "image/gif",
        ".webp": "image/webp", ".bmp": "image/bmp"
    }
    mime_type = mime_map.get(ext, "image/jpeg")

    # Read and encode
    with open(path, "rb") as f:
        encoded = base64.b64encode(f.read()).decode("ascii")

    return f"data:{mime_type};base64,{encoded}"


def _ensure_dir():
    STATE_DIR.mkdir(parents=True, exist_ok=True)


def _ensure_history_dir():
    HISTORY_DIR.mkdir(parents=True, exist_ok=True)


def _load_raw_config() -> dict:
    """Load config from config.json. Warns on parse errors (does NOT swallow)."""
    if CONFIG_FILE.exists():
        try:
            with open(CONFIG_FILE, "r") as f:
                return json.load(f)
        except json.JSONDecodeError as e:
            _log(f"WARNING: {CONFIG_FILE} has invalid JSON: {e}")
        except IOError as e:
            _log(f"WARNING: cannot read {CONFIG_FILE}: {e}")
    return {}


def _get_config() -> dict:
    """
    Resolve full config.

    Priority: env var ARTCLAW_API_KEY > config.json > defaults.
    """
    [REDACTED_SECRET]"ARTCLAW_API_KEY", "")
    base_url = os.environ.get("ARTCLAW_BASE_URL", "")

    file_cfg = {}
    if not [REDACTED_SECRET] = _load_raw_config()
        [REDACTED_SECRET]"apiKey", "")
    if not base_url:
        base_url = file_cfg.get("baseUrl", "") if file_cfg else ""

    return {
        "apiKey": api_key,
        "baseUrl": base_url or DEFAULT_BASE_URL,
    }


def _check_api_key(config: dict, *, allow_dry_run: bool = False):
    """Exit with setup instructions if API key is missing.

    If allow_dry_run=True and --dry-run is active, skip the check.
    """
    if allow_dry_run:
        return
    if not config.get("apiKey"):
        print(json.dumps({
            "error": "ArtClaw API key not configured",
            "setup_required": True,
            "step1": f"Visit {GET_KEY_URL} to get your API key (starts with vk_)",
            "step2": 'Run: artclaw.py config-init --api-key "vk_your_key"',
            "step3": "Or set env var: export ARTCLAW_API_KEY=vk_your_key",
        }, indent=2), file=sys.stderr)
        sys.exit(1)


# ---------------------------------------------------------------------------
# Section 4: Low-Level HTTP — Unified _request_with_retry
# ---------------------------------------------------------------------------

def _log(msg: str):
    """Print progress info to stderr (never to stdout)."""
    print(f"[artclaw] {msg}", file=sys.stderr, flush=True)


def _parse_error_response(resp: requests.Response) -> ArtClawApiError:
    """Parse an error HTTP response into ArtClawApiError."""
    try:
        body = resp.json()
        return ArtClawApiError(
            message=body.get("detail", body.get("message", f"HTTP {resp.status_code}")),
            status_code=resp.status_code,
            code=str(body.get("code", "")),
            detail=body.get("details", body.get("data")),
        )
    except (ValueError, KeyError):
        return ArtClawApiError(
            message=f"HTTP {resp.status_code}: {resp.text[:500]}",
            status_code=resp.status_code,
        )


def _request_with_retry(
    method: str,
    url: str,
    *,
    api_key: str = "",
    json_body: Optional[dict] = None,
    params: Optional[dict] = None,
    max_retries: int = MAX_RETRIES,
    connect_timeout: int = CONNECT_TIMEOUT,
    read_timeout: int = READ_TIMEOUT,
    idempotency_key: Optional[str] = None,
    dry_run: bool = False,
) -> dict:
    """
    Unified HTTP request with exponential backoff retry.

    - Retries on 5xx, 429, and network errors
    - Does NOT retry other 4xx (client errors)
    - Returns parsed JSON response on 200
    """
    if dry_run:
        return {
            "__dry_run": True,
            "method": method,
            "url": url,
            "body": json_body,
            "params": params,
        }

    headers: Dict[str, str] = {"Accept": "application/json"}
    if [REDACTED_SECRET]"X-API-KEY"] = api_key
    if json_body is not None:
        headers["Content-Type"] = "application/json"
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    last_err: Optional[ArtClawApiError] = None

    for attempt in range(max_retries + 1):
        try:
            resp = requests.request(
                method, url,
                headers=headers,
                json=json_body if json_body is not None else None,
                params=params,
                timeout=(connect_timeout, read_timeout),
            )
            if resp.status_code == 200:
                return resp.json()

            err = _parse_error_response(resp)
            # Don't retry client errors (except 429)
            if 400 <= resp.status_code < 500 and resp.status_code != 429:
                raise err
            last_err = err

        except ArtClawApiError:
            raise
        except requests.RequestException as e:
            last_err = ArtClawApiError(
                f"Network error: {e}", status_code=0, code="network_error",
            )

        if attempt < max_retries:
            wait = 2 ** attempt
            _log(f"Retry {attempt + 1}/{max_retries} in {wait}s...")
            time.sleep(wait)

    raise last_err or ArtClawApiError("Unknown error after retries")


# ---------------------------------------------------------------------------
# Section 5: Generic _poll_until
# ---------------------------------------------------------------------------

def _poll_until(
    config: dict,
    job_id: str,
    job_type: str,
    *,
    dry_run: bool = False,
) -> dict:
    """
    Generic job poller with type-aware intervals and timeouts.

    Uses POLL_PROFILES[job_type] for interval, timeout, backoff, max_interval.
    Returns the full job status dict when status == "success".
    Raises ArtClawPollTimeout or ArtClawPollFailed on failure/timeout.
    """
    if dry_run:
        return {"__dry_run": True, "job_id": job_id, "job_type": job_type,
                "action": "would_poll"}

    profile = POLL_PROFILES.get(job_type, POLL_PROFILES["image"])
    interval = profile["interval"]
    timeout = profile["timeout"]
    backoff = profile["backoff"]
    max_interval = profile["max_interval"]

    base_url = config["baseUrl"]
    [REDACTED_SECRET]"apiKey"]
    start = time.time()
    attempt = 0
    last_status = ""

    while True:
        time.sleep(interval)
        attempt += 1
        elapsed = time.time() - start

        if elapsed > timeout:
            raise ArtClawPollTimeout(
                job_id, job_type, attempt, elapsed, last_status,
            )

        try:
            result = _request_with_retry(
                "GET", f"{base_url}/jobs/{job_id}",
                [REDACTED_SECRET],
            )
        except ArtClawApiError as e:
            if e.is_retryable():
                _log(f"Poll {job_type}: transient error attempt {attempt}: {e}")
                interval = min(interval * backoff, max_interval)
                continue
            # 404 early on can mean job not yet visible
            if e.status_code == 404 and attempt <= 5:
                _log(f"Poll {job_type}: job not found yet (404), retrying...")
                interval = min(interval * backoff, max_interval)
                continue
            raise

        status = result.get("status", "")
        last_status = status

        if attempt % 3 == 0 or status in TERMINAL_STATUSES:
            _log(f"Poll {job_type} [{job_id[:16]}]: attempt={attempt} "
                 f"status={status} elapsed={elapsed:.0f}s")

        if status == "success":
            return result

        if status in TERMINAL_STATUSES:
            error_detail = result.get("metadata", {}).get("error_detail")
            raise ArtClawPollFailed(job_id, status, error_detail)

        interval = min(interval * backoff, max_interval)


# ---------------------------------------------------------------------------
# Section 6: API Endpoint Functions
# ---------------------------------------------------------------------------

# --- Generation (async, returns job_id) ---

def api_generate_image(config: dict, prompt: str, *,
                       aspect_ratio: str = None, resolution: str = None,
                       reference_urls: List[str] = None, model: str = None,
                       callback_url: str = None,
                       idempotency_key: str = None,
                       dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"prompt": prompt}
    if aspect_ratio:
        body["aspect_ratio"] = aspect_ratio
    if resolution:
        body["resolution"] = resolution
    if reference_urls:
        body["reference_urls"] = reference_urls
    if model:
        body["model"] = model
    if callback_url:
        body["callback_url"] = callback_url
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/generate/image",
        [REDACTED_SECRET]"apiKey"], json_body=body,
        idempotency_key=idempotency_key, dry_run=dry_run,
    )


def api_generate_video(config: dict, prompt: str, *,
                       aspect_ratio: str = None, duration: int = None,
                       resolution: str = None,
                       reference_urls: List[str] = None, model: str = None,
                       callback_url: str = None,
                       idempotency_key: str = None,
                       dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"prompt": prompt}
    if aspect_ratio:
        body["aspect_ratio"] = aspect_ratio
    if duration is not None:
        body["duration"] = duration
    if resolution:
        body["resolution"] = resolution
    if reference_urls:
        body["content_items"] = [{"role": "reference_image", "url": u} for u in reference_urls]
    if model:
        body["model"] = model
    if callback_url:
        body["callback_url"] = callback_url
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/generate/video",
        [REDACTED_SECRET]"apiKey"], json_body=body,
        idempotency_key=idempotency_key, dry_run=dry_run,
    )


def api_generate_marketing_image(config: dict, prompt: str, *,
                                 size: str = None,
                                 callback_url: str = None,
                                 dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"prompt": prompt}
    if size:
        body["size"] = size
    if callback_url:
        body["callback_url"] = callback_url
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/generate/marketing-image",
        [REDACTED_SECRET]"apiKey"], json_body=body, dry_run=dry_run,
    )


# --- Workflows ---

def api_list_workflows(config: dict, *, dry_run: bool = False) -> dict:
    # Trailing slash required! Without it: 307 redirect -> 502
    return _request_with_retry(
        "GET", f"{config['baseUrl']}/workflows/",
        [REDACTED_SECRET]"apiKey"], dry_run=dry_run,
    )


def api_run_workflow(config: dict, workflow_id: str, inputs: dict, *,
                     timeout: int = None, callback_url: str = None,
                     dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"inputs": inputs}
    if timeout is not None:
        body["timeout"] = timeout
    if callback_url:
        body["callback_url"] = callback_url
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/workflows/{workflow_id}/run",
        [REDACTED_SECRET]"apiKey"], json_body=body, dry_run=dry_run,
    )


# --- Analysis (sync, returns result directly) ---

def api_analyze_image(config: dict, reference_urls: List[str], *,
                      query: str = "", dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"image_urls": reference_urls}
    if query:
        body["query"] = query
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/analyze/image",
        [REDACTED_SECRET]"apiKey"], json_body=body, dry_run=dry_run,
    )


def api_analyze_video(config: dict, reference_urls: List[str], *,
                      query: str = "", dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"video_url": reference_urls[0]}
    if query:
        body["query"] = query
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/analyze/video",
        [REDACTED_SECRET]"apiKey"], json_body=body, dry_run=dry_run,
    )


def api_analyze_script(config: dict, reference_paths: List[str], *,
                       original_script: str = "",
                       node_to_replace: str = "",
                       dry_run: bool = False) -> dict:
    body: Dict[str, Any] = {"reference_paths": reference_paths}
    if original_script:
        body["original_script"] = original_script
    if node_to_replace:
        body["node_to_replace"] = node_to_replace
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/analyze/script",
        [REDACTED_SECRET]"apiKey"], json_body=body, dry_run=dry_run,
    )


def api_analyze_characters(config: dict, text: str, *,
                           dry_run: bool = False) -> dict:
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/analyze/characters",
        [REDACTED_SECRET]"apiKey"], json_body={"story_text": text},
        dry_run=dry_run,
    )


# --- Job management ---

def api_job_status(config: dict, job_id: str, *,
                   dry_run: bool = False) -> dict:
    return _request_with_retry(
        "GET", f"{config['baseUrl']}/jobs/{job_id}",
        [REDACTED_SECRET]"apiKey"], dry_run=dry_run,
    )


def api_list_jobs(config: dict, *, status: str = None, job_type: str = None,
                  limit: int = None, dry_run: bool = False) -> dict:
    params: Dict[str, Any] = {}
    if status:
        params["status"] = status
    if job_type:
        params["type"] = job_type
    if limit is not None:
        params["limit"] = limit
    # Trailing slash required!
    return _request_with_retry(
        "GET", f"{config['baseUrl']}/jobs/",
        [REDACTED_SECRET]"apiKey"], params=params or None, dry_run=dry_run,
    )


def api_cancel_job(config: dict, job_id: str, *,
                   dry_run: bool = False) -> dict:
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/jobs/{job_id}/cancel",
        [REDACTED_SECRET]"apiKey"], dry_run=dry_run,
    )


# --- Account ---

def api_account_info(config: dict, *, dry_run: bool = False) -> dict:
    return _request_with_retry(
        "GET", f"{config['baseUrl']}/account/info",
        [REDACTED_SECRET]"apiKey"], dry_run=dry_run,
    )


# --- Auth (public, no key required for verify) ---

def api_verify_key(config: dict, api_key_to_verify: str, *,
                   dry_run: bool = False) -> dict:
    return _request_with_retry(
        "POST", f"{config['baseUrl']}/auth/verify",
        json_body={"api_key": api_key_to_verify}, dry_run=dry_run,
    )


# ---------------------------------------------------------------------------
# Section 7: High-Level Operations
# ---------------------------------------------------------------------------

def _collect_optional_args(args, keys: List[str]) -> dict:
    """Extract optional args as dict, skipping None values.

    Replaces medeo's repetitive `if hasattr(args, "x") and args.x:` pattern.
    """
    return {k: getattr(args, k) for k in keys
            if getattr(args, k, None) is not None}


def _validate_url(url: str):
    is_http_url = any(url.startswith(s) for s in ALLOWED_URL_SCHEMES)
    is_image_data_uri = (
        url.startswith(ALLOWED_IMAGE_DATA_URI_PREFIX)
        and ";base64," in url[:128]
    )
    if not (is_http_url or is_image_data_uri):
        print(json.dumps({
            "error": (
                "Invalid URL scheme. Only http://, https://, and "
                "data:image/*;base64,... are allowed."
            ),
            "url": url[:100] + "..." if len(url) > 100 else url,
        }), file=sys.stderr)
        sys.exit(1)


def submit_and_poll(
    config: dict,
    subcommand: str,
    api_fn: Callable,
    api_kwargs: dict,
    *,
    no_wait: bool = False,
    dry_run: bool = False,
) -> dict:
    """Submit an async generation job. If wait=True, poll to completion.

    Returns the full result dict (either submission response or final result).
    """
    started_at = datetime.now(timezone.utc)
    job_type = JOB_TYPE_MAP.get(subcommand, "image")

    _log(f"Submitting {subcommand} job...")
    submit_result = api_fn(config, **api_kwargs, dry_run=dry_run)

    if dry_run:
        return submit_result

    job_id = submit_result.get("job_id", "")
    _log(f"Job submitted: {job_id} (type={job_type})")

    # Save submission record
    record = {
        "job_id": job_id,
        "job_type": job_type,
        "subcommand": subcommand,
        "status": "pending",
        "submitted_at": started_at.isoformat(),
    }
    save_job_record(record)

    if no_wait:
        submit_result["message"] = (
            f"Job submitted. Poll with: artclaw.py job-status --job-id {job_id}"
        )
        return submit_result

    # Poll until completion
    final = _poll_until(config, job_id, job_type)

    elapsed = time.time() - started_at.timestamp()
    final["_cli"] = {
        "subcommand": subcommand,
        "total_duration_seconds": round(elapsed, 1),
    }
    save_job_record(final)
    _log(f"Job complete: {job_id} ({elapsed:.0f}s)")
    return final


def save_job_record(record: dict):
    """Save job record to last_job.json and history (microsecond-precision)."""
    _ensure_dir()
    with open(LAST_JOB_FILE, "w") as f:
        json.dump(record, f, indent=2, ensure_ascii=False)

    _ensure_history_dir()
    # Microsecond precision avoids collisions (medeo used only seconds)
    ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
    hist_file = HISTORY_DIR / f"job_{ts}.json"
    with open(hist_file, "w") as f:
        json.dump(record, f, indent=2, ensure_ascii=False)

    # Prune old history (keep last 50)
    history_files = sorted(HISTORY_DIR.glob("job_*.json"))
    if len(history_files) > 50:
        for old_file in history_files[:-50]:
            old_file.unlink(missing_ok=True)


# ---------------------------------------------------------------------------
# Section 8: Spawn Task Builder
# ---------------------------------------------------------------------------

def _build_delivery_instructions(deliver_to: str, channel: str,
                                 base_dir: str, subcommand: str) -> str:
    """Build channel-specific delivery instructions for spawn task."""
    # Determine if this is image or video generation
    is_image = subcommand in ("generate-image", "generate-marketing-image")

    if channel == "feishu":
        if is_image:
            return (
                "  1. Send via Feishu directly with URL:\n"
                f"     python3 {base_dir}/scripts/feishu_send_video.py "
                f"--image \"<result_url>\" --to \"{deliver_to}\"\n"
            )
        else:  # video
            return (
                "  1. Send via Feishu:\n"
                f"     python3 {base_dir}/scripts/feishu_send_video.py "
                f"--video-url \"<result_url>\" --to \"{deliver_to}\" "
                "--cover-url \"<thumbnail_url>\" --duration <duration_ms>\n"
                "     Note: duration is in milliseconds for Feishu"
            )
    if channel == "telegram":
        if is_image:
            return (
                "  1. Download the result locally (curl -o /tmp/artclaw_result.jpg \"<result_url>\")\n"
                "  2. Send via Telegram:\n"
                f"     TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN "
                f"python3 {base_dir}/scripts/telegram_send_video.py "
                f"--image /tmp/artclaw_result.jpg --to \"{deliver_to}\" "
                "--caption \"Result ready!\"\n"
            )
        else:  # video
            return (
                "  1. Download the result locally (curl -o /tmp/artclaw_result.mp4 \"<result_url>\")\n"
                "  2. Send via Telegram:\n"
                f"     TELEGRAM_BOT_TOKEN=$TELEGRAM_BOT_TOKEN "
                f"python3 {base_dir}/scripts/telegram_send_video.py "
                f"--video /tmp/artclaw_result.mp4 --to \"{deliver_to}\" "
                "--cover-url \"<thumbnail_url>\" --duration <duration_seconds> "
                "--caption \"Result ready!\"\n"
                "     Note: duration is in seconds for Telegram"
            )
    if channel == "discord":
        media_file = "/tmp/artclaw_result.jpg" if is_image else "/tmp/artclaw_result.mp4"
        return (
            f"  1. Download the result locally:\n"
            f"     curl -sL -o {media_file} \"<result_url>\"\n"
            "  2. Use the message tool:\n"
            f"     message(action=\"send\", channel=\"discord\", "
            f"target=\"{deliver_to}\", message=\"Result ready!\", "
            f"filePath=\"{media_file}\")\n"
            "     (25 MB limit; for larger files share URL as link)"
        )
    return (
        f"  1. Deliver the result to \"{deliver_to}\" using the message tool,\n"
        "     or share the result URL as a link."
    )


def build_spawn_task(
    subcommand: str,
    args_dict: dict,
    *,
    deliver_to: Optional[str] = None,
    deliver_channel: Optional[str] = None,
    script_path: Optional[str] = None,
) -> dict:
    """Build a sessions_spawn payload for async generation via another agent.

    Returns:
        {
            "sessions_spawn_args": {"task": ..., "label": ..., "runTimeoutSeconds": ...},
            "command": "python3 .../artclaw.py generate-image --prompt ...",
        }
    """
    if script_path is None:
        script_path = str(Path(__file__).resolve())

    cmd_parts = ["python3", script_path, subcommand]
    for key, value in args_dict.items():
        if value is None or value is False:
            continue
        flag = f"--{key.replace('_', '-')}"
        if isinstance(value, bool) and value:
            cmd_parts.append(flag)
        elif isinstance(value, list):
            cmd_parts.append(flag)
            cmd_parts.extend(str(v) for v in value)
        else:
            cmd_parts.extend([flag, json.dumps(str(value), ensure_ascii=False)
                              if isinstance(value, str) and " " in value
                              else str(value)])

    cmd_str = " ".join(cmd_parts)
    base_dir = str(Path(__file__).resolve().parent.parent)
    deliver_to_str = deliver_to or "<user_id>"
    deliver_channel_str = deliver_channel or "feishu"

    delivery_instructions = _build_delivery_instructions(
        deliver_to_str, deliver_channel_str, base_dir, subcommand,
    )

    label_text = args_dict.get("prompt", subcommand)
    if isinstance(label_text, str) and len(label_text) > 60:
        label_text = label_text[:60] + "..."

    task_text = (
        "You are an ArtClaw creative worker. Execute the following command "
        "and deliver the result to the user.\n\n"
        f"Command:\n```\n{cmd_str}\n```\n\n"
        "This command calls the ArtClaw API and waits for completion. "
        "Typical times: images ~30s, videos ~2-5min, workflows ~5-15min. "
        "Wait for the command to complete — do NOT background it.\n\n"
        "When the command finishes:\n"
        "- If the JSON output contains \"status\": \"success\":\n"
        "  Extract the result URL from the JSON output.\n"
        f"{delivery_instructions}\n"
        "  Always include a short summary: what was generated, settings used.\n"
        "- If error indicates insufficient credits (402), inform the user and "
        f"guide them to top up at {TOPUP_URL}\n"
        "- For other failures, report the error and suggest retry."
    )

    return {
        "sessions_spawn_args": {
            "task": task_text,
            "label": f"artclaw: {label_text}",
            "runTimeoutSeconds": SPAWN_TIMEOUT_SECONDS,
        },
        "command": cmd_str,
    }


def _should_spawn(args) -> bool:
    """Infer spawn mode from args.

    Spawn is enabled if:
    - --spawn is explicitly provided, OR
    - both --deliver-to and --deliver-channel are provided.
    """
    has_deliver_to = bool(getattr(args, "deliver_to", None))
    has_deliver_channel = bool(getattr(args, "deliver_channel", None))

    if has_deliver_to ^ has_deliver_channel:
        print(json.dumps({
            "error": "Both --deliver-to and --deliver-channel are required together",
            "hint": "Provide both for async spawn delivery, or omit both for local execution",
        }, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)

    return bool(getattr(args, "spawn", False) or
                (has_deliver_to and has_deliver_channel))


# ---------------------------------------------------------------------------
# Section 9: CLI Command Handlers
# ---------------------------------------------------------------------------

# --- Generation commands ---

def cmd_generate_image(args, config: dict):
    """Generate an image from a text prompt."""
    dry_run = getattr(args, "dry_run", False)
    _check_api_key(config, allow_dry_run=dry_run)

    api_kwargs: Dict[str, Any] = {"prompt": args.prompt}
    api_kwargs.update(_collect_optional_args(args, [
        "aspect_ratio", "resolution", "model", "callback_url",
    ]))

    # Handle spawn mode differently: don't convert files to base64 yet
    if _should_spawn(args):
        # Pass reference URLs/files as-is to spawn task
        if args.reference_urls:
            api_kwargs["reference_urls"] = args.reference_urls
        if getattr(args, "reference_files", None):
            api_kwargs["reference_files"] = args.reference_files

        result = build_spawn_task(
            "generate-image", api_kwargs,
            deliver_to=getattr(args, "deliver_to", None),
            deliver_channel=getattr(args, "deliver_channel", None),
        )
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return

    # Non-spawn mode: convert reference_files to base64 now
    reference_urls = list(args.reference_urls) if args.reference_urls else []
    if getattr(args, "reference_files", None):
        for file_path in args.reference_files:
            _log(f"Converting local file to base64: {file_path}")
            base64_uri = _file_to_base64(file_path)
            reference_urls.append(base64_uri)

    if reference_urls:
        api_kwargs["reference_urls"] = reference_urls
        for url in reference_urls:
            _validate_url(url)

    result = submit_and_poll(
        config, "generate-image", api_generate_image, api_kwargs,
        no_wait=args.no_wait, dry_run=dry_run,
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_generate_video(args, config: dict):
    """Generate a video from a text prompt."""
    dry_run = getattr(args, "dry_run", False)
    _check_api_key(config, allow_dry_run=dry_run)

    api_kwargs: Dict[str, Any] = {"prompt": args.prompt}
    api_kwargs.update(_collect_optional_args(args, [
        "aspect_ratio", "duration", "resolution", "model", "callback_url",
    ]))

    # Handle spawn mode differently: don't convert files to base64 yet
    if _should_spawn(args):
        # Pass reference URLs/files as-is to spawn task
        if args.reference_urls:
            api_kwargs["reference_urls"] = args.reference_urls
        if getattr(args, "reference_files", None):
            api_kwargs["reference_files"] = args.reference_files

        result = build_spawn_task(
            "generate-video", api_kwargs,
            deliver_to=getattr(args, "deliver_to", None),
            deliver_channel=getattr(args, "deliver_channel", None),
        )
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return

    # Non-spawn mode: convert reference_files to base64 now
    reference_urls = list(args.reference_urls) if args.reference_urls else []
    if getattr(args, "reference_files", None):
        for file_path in args.reference_files:
            _log(f"Converting local file to base64: {file_path}")
            base64_uri = _file_to_base64(file_path)
            reference_urls.append(base64_uri)

    if reference_urls:
        api_kwargs["reference_urls"] = reference_urls
        for url in reference_urls:
            _validate_url(url)

    result = submit_and_poll(
        config, "generate-video", api_generate_video, api_kwargs,
        no_wait=args.no_wait, dry_run=dry_run,
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_generate_marketing_image(args, config: dict):
    """Generate a marketing image with auto-enhanced prompt."""
    dry_run = getattr(args, "dry_run", False)
    _check_api_key(config, allow_dry_run=dry_run)

    api_kwargs: Dict[str, Any] = {"prompt": args.prompt}
    api_kwargs.update(_collect_optional_args(args, ["size", "callback_url"]))

    result = submit_and_poll(
        config, "generate-marketing-image",
        api_generate_marketing_image, api_kwargs,
        no_wait=args.no_wait, dry_run=dry_run,
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


# --- Workflow commands ---

def cmd_list_workflows(args, config: dict):
    """List available preset workflows."""
    dry_run = getattr(args, "dry_run", False)
    _check_api_key(config, allow_dry_run=dry_run)
    result = api_list_workflows(config, dry_run=dry_run)
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_run_workflow(args, config: dict):
    """Execute a preset workflow."""
    dry_run = getattr(args, "dry_run", False)
    _check_api_key(config, allow_dry_run=dry_run)

    try:
        inputs = json.loads(args.inputs)
    except json.JSONDecodeError as e:
        print(json.dumps({
            "error": f"--inputs must be valid JSON: {e}",
            "hint": 'Example: --inputs \'{"prompt": "a cat"}\'',
        }), file=sys.stderr)
        sys.exit(1)

    api_kwargs: Dict[str, Any] = {
        "workflow_id": args.workflow_id,
        "inputs": inputs,
    }
    api_kwargs.update(_collect_optional_args(args, ["timeout", "callback_url"]))

    if _should_spawn(args):
        result = build_spawn_task(
            "run-workflow", api_kwargs,
            deliver_to=getattr(args, "deliver_to", None),
            deliver_channel=getattr(args, "deliver_channel", None),
        )
        print(json.dumps(result, indent=2, ensure_ascii=False))
        return

    result = submit_and_poll(
        config, "run-workflow", api_run_workflow, api_kwargs,
        no_wait=args.no_wait, dry_run=dry_run,
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


# --- Analysis commands (sync) ---

def cmd_analyze_image(args, config: dict):
    """Analyze images with AI vision."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    for url in args.reference_urls:
        _validate_url(url)
    result = api_analyze_image(
        config, args.reference_urls,
        query=getattr(args, "query", "") or "",
        dry_run=getattr(args, "dry_run", False),
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_analyze_video(args, config: dict):
    """Analyze video content with AI."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    for url in args.reference_urls:
        _validate_url(url)
    result = api_analyze_video(
        config, args.reference_urls,
        query=getattr(args, "query", "") or "",
        dry_run=getattr(args, "dry_run", False),
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_analyze_script(args, config: dict):
    """Extract script from video and design interactive nodes."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    for url in args.reference_paths:
        _validate_url(url)
    kwargs: Dict[str, Any] = {"reference_paths": args.reference_paths}
    kwargs.update(_collect_optional_args(args, [
        "original_script", "node_to_replace",
    ]))
    result = api_analyze_script(config, **kwargs,
                                dry_run=getattr(args, "dry_run", False))
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_analyze_characters(args, config: dict):
    """Parse character profiles from story text."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    result = api_analyze_characters(
        config, args.text,
        dry_run=getattr(args, "dry_run", False),
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


# --- Job management commands ---

def cmd_job_status(args, config: dict):
    """Check a job's current status."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    result = api_job_status(config, args.job_id,
                            dry_run=getattr(args, "dry_run", False))
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_list_jobs(args, config: dict):
    """List historical jobs."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    kwargs = _collect_optional_args(args, ["status", "job_type", "limit"])
    result = api_list_jobs(config, **kwargs,
                           dry_run=getattr(args, "dry_run", False))
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_cancel_job(args, config: dict):
    """Cancel a pending or running job."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    result = api_cancel_job(config, args.job_id,
                            dry_run=getattr(args, "dry_run", False))
    print(json.dumps(result, indent=2, ensure_ascii=False))


# --- Account commands ---

def cmd_account_info(args, config: dict):
    """Show account balance and usage."""
    _check_api_key(config, allow_dry_run=getattr(args, "dry_run", False))
    result = api_account_info(config,
                              dry_run=getattr(args, "dry_run", False))
    print(json.dumps(result, indent=2, ensure_ascii=False))


def cmd_verify_key(args, config: dict):
    """Verify an API key is valid (no auth required)."""
    key = args.api_key or config.get("apiKey", "")
    if not key:
        print(json.dumps({"error": "No API key provided or configured"}),
              file=sys.stderr)
        sys.exit(1)
    result = api_verify_key(config, key,
                            dry_run=getattr(args, "dry_run", False))
    print(json.dumps(result, indent=2, ensure_ascii=False))

# --- Config commands ---

def cmd_config(args, config: dict):
    """Show current configuration."""
    safe = dict(config)
    key = safe.get("apiKey", "")
    if key and len(key) > 10:
        safe["apiKey"] = key[:6] + "..." + key[-4:]
    elif key:
        safe["apiKey"] = "***"
    safe["source_env_var"] = "ARTCLAW_API_KEY"
    safe["config_file"] = str(CONFIG_FILE)
    safe["state_dir"] = str(STATE_DIR)
    print(json.dumps(safe, indent=2, ensure_ascii=False))


def cmd_config_init(args, config: dict):
    """Initialize or update config.json with API key."""
    _ensure_dir()

    if not args.api_key.startswith("vk_"):
        _log(f"WARNING: API key should start with 'vk_'. Got: {args.api_key[:8]}...")

    raw = _load_raw_config()
    raw["apiKey"] = args.api_key
    raw.setdefault("baseUrl", DEFAULT_BASE_URL)

    with open(CONFIG_FILE, "w") as f:
        json.dump(raw, f, indent=2, ensure_ascii=False)

    _log(f"Config saved to {CONFIG_FILE}")
    print(json.dumps({
        "status": "ok",
        "config_file": str(CONFIG_FILE),
    }, indent=2))


# --- Spawn task command ---

def cmd_spawn_task(args, config: dict):
    """Build sessions_spawn payload for async generation."""
    _check_api_key(config)

    args_dict: Dict[str, Any] = {"prompt": args.prompt}
    args_dict.update(_collect_optional_args(args, [
        "aspect_ratio", "resolution", "duration", "model",
        "size", "workflow_id", "inputs",
    ]))
    if args.reference_urls:
        args_dict["reference_urls"] = args.reference_urls
    if args.no_wait:
        args_dict["no_wait"] = True

    result = build_spawn_task(
        args.subcommand,
        args_dict,
        deliver_to=getattr(args, "deliver_to", None),
        deliver_channel=getattr(args, "deliver_channel", None),
    )
    print(json.dumps(result, indent=2, ensure_ascii=False))


# --- Self-update ---

def _repo_root() -> Path:
    """Return the root directory of the artclaw-skill repo.

    Assumes this script lives at <repo_root>/scripts/artclaw.py.
    """
    return Path(__file__).resolve().parent.parent


def _apply_archive(archive_zip_bytes: bytes, repo_root: Path, *,
                   dry_run: bool = False) -> dict:
    """Extract a GitHub main.zip archive over repo_root.

    The zip contains a single top-level directory (e.g. artclaw-skill-main/).
    We strip that prefix and merge the contents into repo_root.

    Returns a summary dict with lists of added/modified/deleted files.
    """
    import io
    import zipfile
    import shutil
    import tempfile

    with zipfile.ZipFile(io.BytesIO(archive_zip_bytes)) as zf:
        names = zf.namelist()

    # Determine the top-level prefix to strip (e.g. "artclaw-skill-main/")
    prefix = names[0] if names[0].endswith("/") else names[0].split("/")[0] + "/"

    # Collect incoming file paths (relative to repo_root) and their content
    incoming: Dict[str, bytes] = {}
    with zipfile.ZipFile(io.BytesIO(archive_zip_bytes)) as zf:
        for info in zf.infolist():
            if info.is_dir():
                continue
            rel = info.filename
            if not rel.startswith(prefix):
                continue
            rel_path = rel[len(prefix):]  # strip top-level dir prefix
            if not rel_path:
                continue
            incoming[rel_path] = zf.read(info.filename)

    # Collect existing files under repo_root
    existing: set = set()
    for p in repo_root.rglob("*"):
        if p.is_file():
            existing.add(str(p.relative_to(repo_root)))

    added, modified, unchanged = [], [], []
    for rel_path, new_bytes in sorted(incoming.items()):
        dest = repo_root / rel_path
        if not dest.exists():
            added.append(rel_path)
        else:
            try:
                old_bytes = dest.read_bytes()
            except IOError:
                old_bytes = b""
            if old_bytes != new_bytes:
                modified.append(rel_path)
            else:
                unchanged.append(rel_path)

    # Files in existing but not in incoming are untouched (we never delete)
    # unless --delete is someday added.

    if dry_run:
        return {
            "status": "dry_run",
            "added": added,
            "modified": modified,
            "unchanged_count": len(unchanged),
        }

    # Write new/modified files atomically via temp file + rename
    for rel_path in added + modified:
        dest = repo_root / rel_path
        dest.parent.mkdir(parents=True, exist_ok=True)
        tmp_fd, tmp_path = tempfile.mkstemp(dir=dest.parent,
                                            prefix=".artclaw_upd_")
        try:
            with os.fdopen(tmp_fd, "wb") as f:
                f.write(incoming[rel_path])
            if dest.exists():
                shutil.copymode(str(dest), tmp_path)
            os.replace(tmp_path, str(dest))
        except OSError as e:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise OSError(f"Failed to write {dest}: {e}") from e

    return {
        "status": "updated",
        "added": added,
        "modified": modified,
        "unchanged_count": len(unchanged),
    }


def cmd_self_update(args, config: dict):
    """Update the entire artclaw-skill repo from GitHub (downloads archive ZIP)."""
    dry_run = getattr(args, "dry_run", False)
    repo_root = _repo_root()

    _log(f"Downloading archive from {GITHUB_ARCHIVE_URL} ...")
    _log(f"Repo root: {repo_root}")

    try:
        resp = requests.get(
            GITHUB_ARCHIVE_URL,
            timeout=(CONNECT_TIMEOUT, 120),
            stream=False,
        )
        resp.raise_for_status()
    except requests.RequestException as e:
        print(json.dumps({
            "error": f"Failed to download archive: {e}",
            "url": GITHUB_ARCHIVE_URL,
        }, indent=2), file=sys.stderr)
        sys.exit(1)

    _log(f"Downloaded {len(resp.content):,} bytes. Applying update...")

    try:
        summary = _apply_archive(resp.content, repo_root, dry_run=dry_run)
    except OSError as e:
        print(json.dumps({"error": str(e)}, indent=2), file=sys.stderr)
        sys.exit(1)

    if not summary["added"] and not summary["modified"]:
        summary["message"] = "Already up to date. No files changed."
    else:
        n_add = len(summary["added"])
        n_mod = len(summary["modified"])
        parts = []
        if n_add:
            parts.append(f"{n_add} added")
        if n_mod:
            parts.append(f"{n_mod} modified")
        action = "Would update" if dry_run else "Updated"
        summary["message"] = f"{action}: {', '.join(parts)}."

    summary["repo_root"] = str(repo_root)
    summary["source"] = GITHUB_ARCHIVE_URL
    print(json.dumps(summary, indent=2, ensure_ascii=False))


# --- History commands ---

def cmd_last_job(args, config: dict):
    """Show the most recent job record."""
    if not LAST_JOB_FILE.exists():
        print(json.dumps({"message": "No job records found."}))
        return
    with open(LAST_JOB_FILE, "r") as f:
        record = json.load(f)
    print(json.dumps(record, indent=2, ensure_ascii=False))


def cmd_history(args, config: dict):
    """Show job history."""
    if not HISTORY_DIR.exists():
        print(json.dumps({"message": "No history found.", "jobs": []}))
        return

    files = sorted(HISTORY_DIR.glob("job_*.json"), reverse=True)
    limit = args.limit
    jobs = []
    for fpath in files[:limit]:
        try:
            with open(fpath, "r") as fh:
                record = json.load(fh)
            jobs.append({
                "file": fpath.name,
                "job_id": record.get("job_id", ""),
                "status": record.get("status", "unknown"),
                "submitted_at": record.get("submitted_at", ""),
                "subcommand": record.get("subcommand", ""),
            })
        except (json.JSONDecodeError, IOError):
            pass

    print(json.dumps({
        "count": len(jobs),
        "total_records": len(files),
        "jobs": jobs,
    }, indent=2, ensure_ascii=False))


# ---------------------------------------------------------------------------
# Section 10: CLI Parser
# ---------------------------------------------------------------------------

def _add_wait_and_dry_run(parser):
    """Add --no-wait and --dry-run flags to async command parsers."""
    parser.add_argument("--no-wait", action="store_true", default=False,
                        help="Return immediately after submission (don't poll)")
    parser.add_argument("--dry-run", action="store_true", default=False,
                        help="Print what would be sent without making API calls")
    parser.add_argument("--callback-url", default=None,
                        help="Webhook URL for completion notification")


def _add_dry_run(parser):
    """Add --dry-run flag to sync command parsers."""
    parser.add_argument("--dry-run", action="store_true", default=False,
                        help="Print what would be sent without making API calls")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="artclaw",
        description="ArtClaw AI Creative Suite CLI — agent-friendly API client",
    )
    sub = parser.add_subparsers(dest="command", help="Available commands")

    # --- generate-image ---
    p = sub.add_parser("generate-image", help="Generate image from prompt")
    p.add_argument("--prompt", required=True, help="Text description of the image")
    p.add_argument("--aspect-ratio", default=None,
                   choices=sorted(VALID_ASPECT_RATIOS),
                   help="Aspect ratio (default: 16:9)")
    p.add_argument("--resolution", default=None,
                   choices=sorted(VALID_RESOLUTIONS_IMAGE),
                   help="Resolution: 1K, 2K, 4K")
    p.add_argument("--reference-urls", nargs="+", default=None,
                   help="Reference images (HTTPS URLs or base64: data:image/png;base64,...)")
    p.add_argument("--reference-files", nargs="+", default=None,
                   help="Reference images (local file paths, auto-converted to base64)")
    p.add_argument("--model", default=None, help="Model ID override")
    p.add_argument("--spawn", action="store_true", default=False,
                   help="Output sessions_spawn_args payload instead of running directly")
    p.add_argument("--deliver-to", default=None,
                   help="Delivery target (ou_xxx, chat_id, channel_id) — used with --spawn")
    p.add_argument("--deliver-channel", default=None,
                   choices=["feishu", "telegram", "discord"],
                   help="Delivery channel — used with --spawn")
    _add_wait_and_dry_run(p)

    # --- generate-video ---
    p = sub.add_parser("generate-video", help="Generate video from prompt")
    p.add_argument("--prompt", required=True, help="Text description of the video")
    p.add_argument("--aspect-ratio", default=None,
                   choices=sorted(VALID_ASPECT_RATIOS),
                   help="Aspect ratio (default: 16:9)")
    p.add_argument("--duration", type=int, default=None,
                   help="Video duration in seconds (2-12)")
    p.add_argument("--resolution", default=None,
                   choices=sorted(VALID_RESOLUTIONS_VIDEO),
                   help="Resolution: 480p, 720p, 1080p")
    p.add_argument("--reference-urls", nargs="+", default=None,
                   help="Reference images for I2V (HTTPS URLs or base64)")
    p.add_argument("--reference-files", nargs="+", default=None,
                   help="Reference images for I2V (local file paths, auto-converted to base64)")
    p.add_argument("--model", default=None, help="Model ID override")
    p.add_argument("--spawn", action="store_true", default=False,
                   help="Output sessions_spawn_args payload instead of running directly")
    p.add_argument("--deliver-to", default=None,
                   help="Delivery target (ou_xxx, chat_id, channel_id) — used with --spawn")
    p.add_argument("--deliver-channel", default=None,
                   choices=["feishu", "telegram", "discord"],
                   help="Delivery channel — used with --spawn")
    _add_wait_and_dry_run(p)

    # --- generate-marketing-image ---
    p = sub.add_parser("generate-marketing-image",
                       help="Generate marketing image with auto-enhanced prompt")
    p.add_argument("--prompt", required=True, help="Marketing image description")
    p.add_argument("--size", default=None,
                   help="Image size (e.g. 1080x1920)")
    _add_wait_and_dry_run(p)

    # --- list-workflows ---
    p = sub.add_parser("list-workflows", help="List available preset workflows")
    _add_dry_run(p)

    # --- run-workflow ---
    p = sub.add_parser("run-workflow", help="Execute a preset workflow")
    p.add_argument("--workflow-id", required=True, help="Workflow ID to run")
    p.add_argument("--inputs", required=True,
                   help='Workflow inputs as JSON string (e.g. \'{"prompt": "a cat"}\')')
    p.add_argument("--timeout", type=int, default=None,
                   help="Workflow timeout in seconds")
    p.add_argument("--spawn", action="store_true", default=False,
                   help="Output sessions_spawn_args payload instead of running directly")
    p.add_argument("--deliver-to", default=None,
                   help="Delivery target (ou_xxx, chat_id, channel_id) — used with --spawn")
    p.add_argument("--deliver-channel", default=None,
                   choices=["feishu", "telegram", "discord"],
                   help="Delivery channel — used with --spawn")
    _add_wait_and_dry_run(p)

    # --- analyze-image ---
    p = sub.add_parser("analyze-image", help="Analyze images with AI vision")
    p.add_argument("--reference-urls", nargs="+", required=True,
                   help="Image URLs to analyze")
    p.add_argument("--query", default=None,
                   help="Specific question about the image")
    _add_dry_run(p)

    # --- analyze-video ---
    p = sub.add_parser("analyze-video", help="Analyze video content")
    p.add_argument("--reference-urls", nargs="+", required=True,
                   help="Video URLs to analyze")
    p.add_argument("--query", default=None,
                   help="Specific question about the video")
    _add_dry_run(p)

    # --- analyze-script ---
    p = sub.add_parser("analyze-script",
                       help="Extract script from video + interactive nodes")
    p.add_argument("--reference-paths", nargs="+", required=True,
                   help="Video URLs/paths to analyze")
    p.add_argument("--original-script", default=None,
                   help="Existing script for regeneration mode")
    p.add_argument("--node-to-replace", default=None,
                   help="Specific node to regenerate")
    _add_dry_run(p)

    # --- analyze-characters ---
    p = sub.add_parser("analyze-characters",
                       help="Parse character profiles from story text")
    p.add_argument("--text", required=True,
                   help="Story text to extract characters from")
    _add_dry_run(p)

    # --- job-status ---
    p = sub.add_parser("job-status", help="Check job status")
    p.add_argument("--job-id", required=True, help="Job ID to check")
    _add_dry_run(p)

    # --- list-jobs ---
    p = sub.add_parser("list-jobs", help="List historical jobs")
    p.add_argument("--status", default=None,
                   help="Filter by status (pending, running, success, failed, ...)")
    p.add_argument("--job-type", default=None,
                   help="Filter by type (image_generation, video_generation, ...)")
    p.add_argument("--limit", type=int, default=None,
                   help="Number of jobs to return (1-100)")
    _add_dry_run(p)

    # --- cancel-job ---
    p = sub.add_parser("cancel-job", help="Cancel a pending/running job")
    p.add_argument("--job-id", required=True, help="Job ID to cancel")
    _add_dry_run(p)

    # --- account-info ---
    p = sub.add_parser("account-info", help="Show account balance and usage")
    _add_dry_run(p)

    # --- verify-key ---
    p = sub.add_parser("verify-key",
                       help="Verify an API key (no auth required)")
    p.add_argument("--api-key", default=None,
                   help="API key to verify (uses configured key if omitted)")
    _add_dry_run(p)

    # --- config ---
    sub.add_parser("config", help="Show current configuration")

    # --- config-init ---
    p = sub.add_parser("config-init", help="Initialize or update config")
    p.add_argument("--api-key", required=True,
                   help="ArtClaw API key (starts with vk_)")

    # --- spawn-task ---
    p = sub.add_parser("spawn-task",
                       help="Build sessions_spawn payload for async agent execution")
    p.add_argument("--subcommand", required=True,
                   choices=["generate-image", "generate-video",
                            "generate-marketing-image", "run-workflow"],
                   help="Which generation command to spawn")
    p.add_argument("--prompt", default=None, help="Generation prompt")
    p.add_argument("--aspect-ratio", default=None, help="Aspect ratio")
    p.add_argument("--resolution", default=None, help="Resolution")
    p.add_argument("--duration", type=int, default=None,
                   help="Video duration (seconds)")
    p.add_argument("--model", default=None, help="Model ID override")
    p.add_argument("--size", default=None, help="Marketing image size")
    p.add_argument("--workflow-id", default=None, help="Workflow ID")
    p.add_argument("--inputs", default=None, help="Workflow inputs (JSON)")
    p.add_argument("--reference-urls", nargs="+", default=None,
                   help="Reference URLs")
    p.add_argument("--no-wait", action="store_true", default=False,
                   help="Don't wait for completion in spawned task")
    p.add_argument("--deliver-to", default=None,
                   help="Delivery target (ou_xxx, chat_id, channel_id)")
    p.add_argument("--deliver-channel", default=None,
                   choices=["feishu", "telegram", "discord"],
                   help="Delivery channel")

    # --- last-job ---
    sub.add_parser("last-job", help="Show last job record")

    # --- history ---
    p = sub.add_parser("history", help="Show local job history")
    p.add_argument("--limit", type=int, default=20,
                   help="Number of records to show")

    # --- self-update ---
    p = sub.add_parser("self-update",
                       help="Update the entire artclaw-skill repo from GitHub")
    _add_dry_run(p)

    return parser


# ---------------------------------------------------------------------------
# Section 11: Command Map & Main Entry Point
# ---------------------------------------------------------------------------

COMMAND_MAP = {
    "generate-image": cmd_generate_image,
    "generate-video": cmd_generate_video,
    "generate-marketing-image": cmd_generate_marketing_image,
    "list-workflows": cmd_list_workflows,
    "run-workflow": cmd_run_workflow,
    "analyze-image": cmd_analyze_image,
    "analyze-video": cmd_analyze_video,
    "analyze-script": cmd_analyze_script,
    "analyze-characters": cmd_analyze_characters,
    "job-status": cmd_job_status,
    "list-jobs": cmd_list_jobs,
    "cancel-job": cmd_cancel_job,
    "account-info": cmd_account_info,
    "verify-key": cmd_verify_key,
    "config": cmd_config,
    "config-init": cmd_config_init,
    "spawn-task": cmd_spawn_task,
    "last-job": cmd_last_job,
    "history": cmd_history,
    "self-update": cmd_self_update,
}


def main():
    parser = build_parser()
    args = parser.parse_args()

    if not args.command:
        parser.print_help()
        sys.exit(1)

    config = _get_config()

    handler = COMMAND_MAP.get(args.command)
    if not handler:
        parser.print_help()
        sys.exit(1)

    try:
        handler(args, config)
    except ArtClawApiError as e:
        if e.is_retryable():
            _log(f"[retryable] {e.to_dict()}")
            print(json.dumps({
                "error": "service_unavailable",
                "message": "ArtClaw service is temporarily unavailable. Please try again.",
                "retryable": True,
            }, indent=2), file=sys.stderr)
        else:
            print(json.dumps(e.to_dict(), indent=2, ensure_ascii=False),
                  file=sys.stderr)
        sys.exit(1)
    except ArtClawPollTimeout as e:
        print(json.dumps({
            "error": "poll_timeout",
            "job_id": e.job_id,
            "job_type": e.job_type,
            "elapsed_seconds": round(e.elapsed),
            "last_status": e.last_status,
            "message": (f"Job timed out after {round(e.elapsed)}s. "
                        f"Check with: artclaw.py job-status --job-id {e.job_id}"),
            "retryable": True,
        }, indent=2), file=sys.stderr)
        sys.exit(1)
    except ArtClawPollFailed as e:
        print(json.dumps({
            "error": "job_failed",
            "job_id": e.job_id,
            "status": e.status,
            "detail": e.error_detail,
        }, indent=2, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
    except KeyboardInterrupt:
        _log("Interrupted by user")
        sys.exit(130)


if __name__ == "__main__":
    main()


path: docs/adapters/claude-code.md
content: # Claude Code Adapter

Use this adapter in Claude Code.

## Execution Model

Do not use `--spawn` unless the current Claude Code environment explicitly provides an OpenClaw-compatible spawn tool.

Claude Code has two different async modes. Prefer the first one.

### Preferred: Claude Code Background Task

Use this when Claude Code's `Bash` tool supports `run_in_background`.

Run generation/workflow commands without `--no-wait` and set `run_in_background: true` on the Bash tool call. This creates a Claude Code background task visible through `/tasks`.

Example Bash tool input:

```json
{
  "command": "python3 /absolute/path/to/scripts/artclaw.py generate-image --prompt \"A cute kitten rolling on the floor\" --aspect-ratio 1:1 --resolution 2K",
  "description": "Generate ARTCLAW image",
  "run_in_background": true
}
```

**CRITICAL Rules:**

1. Use `run_in_background: true`, not a long foreground Bash timeout.
2. Do not add `--no-wait` in this mode, because the background task should wait, poll, and return the final media URL.
3. Immediately tell the user that a Claude Code background task was started.
4. The user can inspect it with `/tasks`.
5. **DO NOT manually poll with `job-status` after the background task completes.** The background task already polls internally and returns the final result in its output. When the background task completes, use the `Read` tool to read the output file path (provided in the task completion notification) to get the final result JSON.
6. **The background task output contains the complete result** including `status: "success"` and the media URL. No further polling is needed.

**Do not use `TaskOutput` to check or wait for task status.** Claude Code automatically notifies the agent when the background task finishes. After submission, simply tell the user the task was started and wait for the automatic notification.

### Fallback: ARTCLAW Server Async

**Do not use this fallback unless Claude Code's Bash tool does not support `run_in_background`.** Most Claude Code environments support it.

1. Run `python3 scripts/artclaw.py generate-* ... --no-wait` or `run-workflow ... --no-wait`.
2. Immediately tell the user the job has been submitted and include the `job_id`.
3. Use `job-status` or `last-job` only for follow-up when the user asks.
4. Do not tell the user this created a Claude Code background task; it did not.

Analysis commands are synchronous and may run normally.


path: docs/adapters/openclaw.md
content: # OpenClaw Adapter

Use this adapter in OpenClaw and OpenClaw-compatible environments that expose `sessions_spawn`.

## Execution Model

Generation and workflow commands must use spawn mode:
```bash
python3 scripts/artclaw.py generate-image \
  --prompt "Cyberpunk cityscape at night" \
  --aspect-ratio 16:9 \
  --spawn \
  --deliver-to ou_xxxxxx \
  --deliver-channel feishu
```

Flow:

1. Run the target `generate-*` or `run-workflow` command with `--spawn`.
2. Include `--deliver-to` and `--deliver-channel`.
3. Read `sessions_spawn_args` from the CLI JSON output.
4. Pass `sessions_spawn_args` to `sessions_spawn`.
5. Immediately tell the user the job has been submitted.
6. Do not wait silently in the main agent.

Do not use `--spawn` for analysis commands.



path: docs/adapters/others.md
content: # Generic Adapter

Use this adapter when the current platform has no dedicated adapter.

First, determine which execution model applies:

## Claw-compatible Platform

If the platform exposes a sub-agent spawn tool (e.g. `sessions_spawn`, `delegate_task`, or equivalent), follow `docs/adapters/openclaw.md` instead of this file.

## Plain CLI Platform

If no spawn tool is available, use this execution model.

### Execution Model

For generation and workflow commands:

1. Do not use `--spawn`.
2. Use `--no-wait` so the CLI submits the job and returns immediately.
3. If the user explicitly asks to wait, you may omit `--no-wait`.
4. Return `job_id` to the user along with the `job-status` command to check progress.

Submit command form:

```bash
python3 scripts/artclaw.py generate-image \
  --prompt "A cute kitten rolling on the floor" \
  --aspect-ratio 1:1 \
  --no-wait
```

Status command:

```bash
python3 scripts/artclaw.py job-status --job-id "job_xxxxxxxx"
```

Analysis commands are synchronous and may run normally.


path: docs/adapters/hermes.md
content: # Hermes Agent Adapter

## PROHIBITED: Delivery Tools

**NEVER call any delivery tool in this adapter.** This includes but is not limited to:
`send_feishu`, `feishu_send_video`, `feishu_send_image`, `telegram_send_video`, `telegram_send_image`, or any other IM/delivery tool.

Always reply with the plain result URL as text. The platform delivers it automatically.
Delivery tools may only be called if the user's message **explicitly** requests delivery (e.g. "发到飞书", "send to telegram").

## Formatting Rules

**Never use markdown tables** (`| col | col |` syntax). Feishu does not render them — only the last non-table line will be visible.

Use plain bullet lists instead:
```
- 可用余额：
- 已消耗：
```

---

## Execution Model

**ALL generation commands run in background. On completion: read result from file only — NO API polling.**

artclaw.py saves the final job result to `~/.artclaw/last_job.json` when a job completes (including `result_url`). Use this file instead of calling `job-status`.

### Correct Sequence

```text
# 0. Pre-flight (per SKILL.md Rule 2)
terminal: python3 scripts/artclaw.py verify-key

# 1. Submit job in background (WITHOUT --no-wait — let it run to completion)
terminal(
  command: "python3 scripts/artclaw.py generate-image --prompt '...' --aspect-ratio 1:1",
  background: true,
  notify_on_complete: true
)

# 2. Reply immediately
任务已在后台启动，完成后会自动通知。

# 3. On notify: read the result from last_job.json — NO job-status API call
```

### Step-by-Step

**Submit:**
```
terminal(background=true, notify_on_complete=true)
python3 scripts/artclaw.py generate-image --prompt "A cute kitten" --aspect-ratio 1:1
```

**On notify (background process exited):**
```
read_file("~/.artclaw/last_job.json")
→ Verify json["job_id"] matches the submitted job_id
→ If json["status"] == "success": reply with json["result"]["result_url"] as plain text (e.g. "图片已生成：https://...")
→ If json["status"] != "success": reply with json.get("error") or "生成失败，请重试"
→ Do NOT use MEDIA: prefix — output the raw URL directly so the platform can render it
```

### Why last_job.json

`artclaw.py` calls `save_job_record(final)` on job completion. This writes to `~/.artclaw/last_job.json` with fields:
- `job_id`
- `status` ("success" / "failed")
- `result` (contains `result_url`)
- `created_at`, `updated_at`

Reading this file is instant, free, and produces no API traffic.

### Anti-Patterns (Never Do These)

- `job-status` API call after notification arrives
- `sleep && job-status` polling loop
- `job-status` in the main turn or in a delegate
- Using `--no-wait` with this adapter

### Fallback: delegate_task for isolation

If the main turn is interrupted before the notify arrives, use delegate_task with the job_id from the submit output:

```
delegate_task(
  goal: "Wait for artclaw job to complete, then read result from file.",
  context: "job_id=XXX. Read the result from ~/.artclaw/last_job.json.
            The file has fields: job_id, status (success/failed), result.result_url.
            Once status is terminal (success/failed), reply in current conversation with result_url or error.
            Do NOT call job-status API. Do NOT poll. Read the file once.",
  toolsets: ["terminal", "file"]
)
```

### Analysis Commands

Synchronous — run normally without background.


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