Skill: goforu/phoenixclaw-image-gen (version 0.2.1)

--- name: image-generation description: 图像生成辅助。支持通过 OpenRouter 直接调用各种生图模型（如 Seedream），为 OpenClaw 优化，支持提示词、尺寸等参数配置。目前仅限 OpenRouter provider。 license: MIT compatibility: OpenClaw, Claude Code, OpenCode, Antigravity metadata: version: "0.2.1" openclaw: emoji: "🎨" requires: env: ["OPENROUTER_API_KEY"] optional: ["IMAGE_GEN_TEXT_TO_IMAGE_MODEL", "IMAGE_GEN_IMAGE_TO_IMAGE_MODEL"] primaryEnv: "OPENROUTER_API_KEY" --- # 图像生成辅助 为 OpenClaw 提供原生的图像生成能力，通过文字描述词快速生成高质量图像。 ## 工作流程 ``` 用户提示词 (Prompt) ↓ ┌─────────────────────────────────────┐ │ 识别参数：模型、尺寸、比例、路径 │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 调用 OpenRouter 接口 (OpenClaw) │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 默认模型：seedream-4.5 (Text-to-Image) & Gemini 2.5 (Image-to-Image) │ └─────────────────────────────────────┘ ↓ ┌─────────────────────────────────────┐ │ 原子化写入本地存储 (Safety First) │ └─────────────────────────────────────┘ ↓ 返回结构化 JSON (Success Payload) ``` ## 安装与配置 ### 1. 获取 API Key 本技能需要 **OpenRouter API Key** 才能调用图像生成服务。 1. 访问 [OpenRouter 控制台](https://openrouter.ai/keys) 创建 API Key 2. 请根据下方指引手动配置环境变量 ### 2. 配置到环境变量 根据 OpenClaw 官方最佳实践，请通过 `~/.openclaw/openclaw.json` 配置环境变量： ```json5 { skills: { entries: { "image-generation": { enabled: true, env: { OPENROUTER_API_KEY: "sk-or-v1-xxxxxxxx..." } } } } } ``` 或使用 `primaryEnv` 快捷配置： ```json5 { skills: { entries: { "image-generation": { enabled: true, [REDACTED_SECRET]" } } } } ``` 配置完成后无需重启，新配置会在下次运行 OpenClaw 时自动生效。 ### 3. 选择默认模型（可选） 本技能默认使用 `bytedance-seed/seedream-4.5` 作为文本生图模型，`google/gemini-2.5-flash-image` 作为图生图模型。你可以通过以下方式更改： **方式一：环境变量配置** 在 `~/.openclaw/openclaw.json` 中添加： ```json5 { skills: { entries: { "image-generation": { enabled: true, env: { OPENROUTER_API_KEY: "sk-or-v1-xxx", IMAGE_GEN_TEXT_TO_IMAGE_MODEL: "bytedance-seed/seedream-4.5", IMAGE_GEN_IMAGE_TO_IMAGE_MODEL: "google/gemini-2.5-flash-image" } } } } } ``` 本技能默认使用 `bytedance-seed/seedream-4.5` 模型，你可以通过以下方式更改： **方式一：环境变量配置** 在 `~/.openclaw/openclaw.json` 中添加： ```json5 { skills: { entries: { "image-generation": { enabled: true, env: { OPENROUTER_API_KEY: "sk-or-v1-xxx", IMAGE_GEN_IMAGE_TO_IMAGE_MODEL: "google/gemini-2.5-flash-image", IMAGE_GEN_TEXT_TO_IMAGE_MODEL: "bytedance-seed/seedream-4.5" } } } } ``` **方式二：查看可用模型** ```bash node skills/image-generation/scripts/cli/openrouter.js --list-models ``` **方式三：生成时指定模型** ```bash node skills/image-generation/scripts/generate.js \ --prompt "a futuristic city" \ --model "bytedance-seed/seedream-4.5" \ --i2i-model "google/gemini-2.5-flash-image" ``` ```bash node skills/image-generation/scripts/generate.js \ --prompt "a futuristic city" \ --image "bytedance-seed/seedream-4.5" \ --model "google/gemini-2.5-flash-image" ``` **推荐模型**： - `bytedance-seed/seedream-4.5` - 默认，高质量图像生成 - `anthropic/claude-3.5-sonnet-image` - Claude 图像生成 - `openai/dall-e-3` - DALL-E 3 ### 4. 验证配置 ```bash node skills/image-generation/scripts/cli/openrouter.js --test ``` 预期输出： ```json { "success": true, "message": "OpenRouter API key is configured. Test passed." } ``` ## 核心配置 ### 仅限 OpenRouter (v1) 本技能在 v1 版本中**仅支持**通过 OpenRouter 提供商进行图像生成。目前不原生支持 Anthropic、Replicate 或 Stability AI 等直接接口。所有生图请求均通过 OpenRouter 统一中转。 ### 默认模型 - **文本生图模型 (Text-to-Image)**: `bytedance-seed/seedream-4.5` - **图生图模型 (Image-to-Image)**: `google/gemini-2.5-flash-image` - **获取地址**: [OpenRouter | bytedance-seed/seedream-4.5](https://openrouter.ai/bytedance-seed/seedream-4.5) ## OpenClaw 调用方式 ### 命令调用 OpenClaw 会通过以下 CLI 方式触发图像生成： ```bash # 基础生成 node skills/image-generation/scripts/generate.js \ --prompt "a futuristic city at sunset" \ --output "outputs/city.png" # 指定模型、尺寸与比例 (OpenRouter 默认) node skills/image-generation/scripts/generate.js \ --prompt "cyberpunk landscape" \ --model "bytedance-seed/seedream-4.5" \ --i2i-model "google/gemini-2.5-flash-image" \ --size "2K" \ --aspect "16:9" node skills/image-generation/scripts/generate.js \ --prompt "cyberpunk landscape" \ --image "bytedance-seed/seedream-4.5" \ --model "google/gemini-2.5-flash-image" \ --size "2K" \ --aspect "16:9" # 通过 OpenClaw 包装器调用 node skills/image-generation/scripts/cli/openrouter.js \ --prompt "abstract oil painting" \ --size "1K" ### 预检测 (Connectivity Check) OpenClaw 在启动时可运行以下脚本检查 API 连通性： ```bash node skills/image-generation/scripts/cli/openrouter.js --test ``` ## 参数详解 | 参数 | 说明 | 必填 | 默认值 | |------|------|------|--------| | `--prompt` | 图像描述词 | 是 | - | | `--model` | 文本生图模型 ID | 否 | `bytedance-seed/seedream-4.5` | | `--i2i-model` | 图生图模型 ID | 否 | `google/gemini-2.5-flash-image` | | `--input-image`| 图生图输入图片路径 | 否 | - | | `--list-models` | 列出所有可用模型 | 否 | - | | `--size` | 分辨率等级 (`1K`\|`2K`\|`4K`) | 否 | 模型默认 | | `--aspect` | 宽高比 (如 1:1, 16:9) | 否 | `1:1` | | `--output` | 输出文件路径 | 否 | `.sisyphus/generated/image_<ts>.png` | |------|------|------|--------| | `--prompt` | 图像描述词 | 是 | - | || `--model` | 图生图模型 ID | 否 | `google/gemini-2.5-flash-image` | || `--image` | 文本生图模型 ID | 否 | `bytedance-seed/seedream-4.5` | | `--list-models` | 列出所有可用模型 | 否 | - | | `--size` | 分辨率等级 (`1K`\|`2K`\|`4K`) | 否 | 模型默认 | | `--aspect` | 宽高比 (如 1:1, 16:9) | 否 | `1:1` | | `--output` | 输出文件路径 | 否 | `.sisyphus/generated/image_<ts>.png` | | `--output` | 输出文件路径 | 否 | `.sisyphus/generated/image_<ts>.png` | ### 分辨率与尺寸说明 **`--size` 参数格式**： - 只接受三个字符串值：`"1K"`、`"2K"`、`"4K"` - **不要** 使用像素格式如 `"1024x1024"` 或 `"3840x2160"`（OpenRouter API 不接受） **实际输出像素尺寸**（由 `size` 和 `aspect` 共同决定）： | size | aspect | 实际像素 | 说明 | |------|--------|----------|------| | `1K` | `1:1` | 1024×1024 | 默认，约 1MP | | `1K` | `16:9` | ~1280×720 | 约 0.9MP | | `2K` | `1:1` | 2048×2048 | 约 4MP | | `2K` | `16:9` | ~2560×1440 | 约 3.7MP | | `4K` | `1:1` | 4096×4096 | 约 16MP | | `4K` | `16:9` | 3840×2160 | 标准 4K UHD | **重要限制**： 1. **并非所有模型都支持 4K**，部分模型最高只支持到 2K 2. **4K 生成成本更高**（约 2 倍于 1K/2K） 3. **推荐**：日常使用 `2K`（与 1K 同价，质量显著提升） **支持的宽高比**：`1:1`（默认）、`2:3`、`3:2`、`3:4`、`4:3`、`4:5`、`5:4`、`9:16`、`16:9`、`21:9` ## 退出码说明 (Exit Codes) 符合 `cli-contract.md` 规范： | 代码 | 标签 | 描述 | |------|-------|-------------| | `0` | `SUCCESS` | 生成成功并已保存到本地 | | `1` | `CONFIG_ERROR` | 参数缺失、格式错误或鉴权失败 | | `2` | `API_ERROR` | OpenRouter API 调用失败（限流、超时等） | | `3` | `FS_ERROR` | 本地文件系统错误（目录权限、磁盘空间等） | ## 验证与证据 (Verification) 在交付前，请确保通过以下冒烟测试： 1. **环境检查**: `node skills/image-generation/scripts/cli/openrouter.js --test` (返回 success: true) 2. **基础生成**: `node skills/image-generation/scripts/generate.js --prompt "test" --output "test.png"` 3. **路径安全**: 确保生成的图片位于 `.sisyphus/generated/` 或指定的安全路径下。 --- ## 资源维护 - **扩展新 Provider**: 参考 `references/extension-guide.md` - **配置详情**: 参考 `references/configuration.md` - **CLI 规范**: 参考 `references/cli-contract.md`

path: scripts/generate.js
content: #!/usr/bin/env node

/**
 * Image Generation CLI Script
 *
 * Main entry point for image generation with:
 * - Output path safety validation
 * - Retry logic for transient errors
 * - Atomic file writes
 * - Deterministic error codes per cli-contract.md
 *
 * Exit Codes:
 *   0 = SUCCESS
 *   1 = CONFIG_ERROR (missing params, invalid flags, auth issues)
 *   2 = API_ERROR (rate limits, server errors)
 *   3 = FS_ERROR (filesystem issues)
 *
 * @module generate
 */

const fs = require('fs');
const path = require('path');

const openrouter = require('./adapters/openrouter');

// Exit codes matching cli-contract.md
const ExitCodes = {
    SUCCESS: 0,
    CONFIG_ERROR: 1,
    API_ERROR: 2,
    FS_ERROR: 3
};

// Retry configuration
const RETRY_CONFIG = {
    maxAttempts: 3,
    delays: [1000, 2000, 4000] // Exponential backoff: 1s, 2s, 4s
};

// Transient HTTP status codes that should trigger retry
const TRANSIENT_STATUS_CODES = [429, 502, 503];

/**
 * Creates a structured error object following cli-contract.md schema
 *
 * @param {string} code - Error code
 * @param {string} message - Human-readable error message
 * @param {number} statusCode - HTTP-like status code
 * @param {Object} [details={}] - Additional error details
 * @returns {Object} Structured error object
 */
function createError(code, message, statusCode, details = {}) {
    return {
        success: false,
        error: code,
        message: message,
        details: details,
        statusCode: statusCode,
        timestamp: new Date().toISOString()
    };
}

/**
 * Validates that an output path is safe (no directory traversal)
 *
 * Rejects:
 * - Paths containing '..' (directory traversal)
 * - Paths starting with '~' (home directory)
 * - Absolute paths outside current working directory
 * - Paths with null bytes
 *
 * @param {string} outputPath - The path to validate
 * @returns {Object} Validation result { valid: boolean, error?: Object }
 */
function validateOutputPath(outputPath) {
    // Check for null bytes
    if (outputPath.includes('\0')) {
        return {
            valid: false,
            error: createError(
                'CONFIG_ERROR',
                'Output path contains invalid null bytes',
                400,
                { path: outputPath }
            )
        };
    }

    // Reject paths with directory traversal
    if (outputPath.includes('..')) {
        return {
            valid: false,
            error: createError(
                'CONFIG_ERROR',
                'Output path contains directory traversal ("..") which is not allowed',
                400,
                { path: outputPath }
            )
        };
    }

    // Reject paths starting with ~ (home directory)
    if (outputPath.startsWith('~')) {
        return {
            valid: false,
            error: createError(
                'CONFIG_ERROR',
                'Output path cannot start with "~" (home directory)',
                400,
                { path: outputPath }
            )
        };
    }

    // Resolve to absolute path
    const resolvedPath = path.resolve(outputPath);
    const cwd = process.cwd();

    // For absolute paths, ensure they're within cwd
    if (path.isAbsolute(outputPath)) {
        // Check if path is within current working directory
        const relativePath = path.relative(cwd, resolvedPath);
        if (relativePath.startsWith('..') || relativePath === '') {
            return {
                valid: false,
                error: createError(
                    'CONFIG_ERROR',
                    'Absolute output path must be within current working directory',
                    400,
                    { path: outputPath, cwd: cwd }
                )
            };
        }
    }

    return { valid: true, resolvedPath };
}

/**
 * Ensures the parent directory exists, creating it if necessary
 *
 * @param {string} filePath - Path to the file
 * @returns {Object} Result { success: boolean, error?: Object }
 */
function ensureDirectoryExists(filePath) {
    const parentDir = path.dirname(filePath);

    try {
        // Check if directory exists
        const stats = fs.statSync(parentDir);
        if (!stats.isDirectory()) {
            return {
                success: false,
                error: createError(
                    'FS_ERROR',
                    `Parent path exists but is not a directory: ${parentDir}`,
                    500,
                    { path: parentDir }
                )
            };
        }
        return { success: true };
    } catch (error) {
        // Directory doesn't exist, try to create it
        if (error.code === 'ENOENT') {
            try {
                fs.mkdirSync(parentDir, { recursive: true });
                return { success: true };
            } catch (mkdirError) {
                return {
                    success: false,
                    error: createError(
                        'FS_ERROR',
                        `Failed to create output directory: ${mkdirError.message}`,
                        500,
                        { path: parentDir, error: mkdirError.message }
                    )
                };
            }
        }

        return {
            success: false,
            error: createError(
                'FS_ERROR',
                `Failed to access output directory: ${error.message}`,
                500,
                { path: parentDir, error: error.message }
            )
        };
    }
}

/**
 * Writes a file atomically by writing to a temp file and renaming
 *
 * This prevents partial file corruption on crash or failure.
 *
 * @param {string} filePath - Final destination path
 * @param {Buffer} data - File data to write
 * @returns {Object} Result { success: boolean, error?: Object }
 */
function writeFileAtomic(filePath, data) {
    const tempPath = `${filePath}.tmp.${Date.now()}.${process.pid}`;

    try {
        // Write to temp file
        fs.writeFileSync(tempPath, data);

        // Verify temp file was written correctly
        const tempStats = fs.statSync(tempPath);
        if (tempStats.size !== data.length) {
            // Clean up temp file
            try {
                fs.unlinkSync(tempPath);
            } catch {
                // Ignore cleanup errors
            }
            return {
                success: false,
                error: createError(
                    'FS_ERROR',
                    'Temp file size mismatch - partial write detected',
                    500,
                    { expected: data.length, actual: tempStats.size }
                )
            };
        }

        // Atomic rename
        fs.renameSync(tempPath, filePath);

        return { success: true };
    } catch (error) {
        // Clean up temp file on error
        try {
            if (fs.existsSync(tempPath)) {
                fs.unlinkSync(tempPath);
            }
        } catch {
            // Ignore cleanup errors
        }

        return {
            success: false,
            error: createError(
                'FS_ERROR',
                `Failed to write output file: ${error.message}`,
                500,
                { path: filePath, error: error.message }
            )
        };
    }
}

/**
 * Sleeps for the specified number of milliseconds
 *
 * @param {number} ms - Milliseconds to sleep
 * @returns {Promise<void>}
 */
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * Checks if an error is a transient error that should trigger retry
 *
 * @param {Object} result - The result object from generateImage
 * @returns {boolean} True if the error is transient
 */
function isTransientError(result) {
    if (result.success) {
        return false;
    }

    const statusCode = result.error?.statusCode;
    const errorCode = result.error?.error;

    // Check for transient HTTP status codes
    if (statusCode && TRANSIENT_STATUS_CODES.includes(statusCode)) {
        return true;
    }

    // Check for network errors (typically connection issues)
    if (errorCode === 'NETWORK_ERROR') {
        return true;
    }

    // Check for rate limit or timeout indicators in message
    const message = result.error?.message || '';
    const transientIndicators = [
        'rate limit',
        'rate-limit',
        'too many requests',
        'timeout',
        'temporary',
        'unavailable',
        'overloaded'
    ];

    return transientIndicators.some(indicator =>
        message.toLowerCase().includes(indicator)
    );
}

/**
 * @param {string} apiKey - OpenRouter API key
 * @param {string} imageToImageModel - Image-to-image model ID
 * @param {string} textToImageModel - Text-to-image model ID
 * @param {string} prompt - Generation prompt
 * @param {string} [size] - Image size
 * @param {string} [aspectRatio] - Aspect ratio
 * @param {string} [inputImagePath] - Path to input image for i2i
 * @returns {Promise<Object>} Result with success flag and data or error
 */
async function generateImageWithRetry(apiKey, imageToImageModel, textToImageModel, prompt, size, aspectRatio, inputImagePath) {
    let lastResult = null;
    let inputImageBase64 = null;

    if (inputImagePath) {
        try {
            const imageBuffer = fs.readFileSync(inputImagePath);
            inputImageBase64 = imageBuffer.toString('base64');
        } catch (error) {
            return {
                success: false,
                error: createError(
                    'FS_ERROR',
                    `Failed to read input image: ${error.message}`,
                    400,
                    { path: inputImagePath }
                )
            };
        }
    }

    for (let attempt = 1; attempt <= RETRY_CONFIG.maxAttempts; attempt++) {
        const result = await openrouter.generateImage(
            apiKey,
            imageToImageModel,
            textToImageModel,
            prompt,
            size,
            aspectRatio,
            inputImageBase64
        );

        if (result.success) {
            return result;
        }

        lastResult = result;

        // Check if this is a transient error that should be retried
        if (isTransientError(result) && attempt < RETRY_CONFIG.maxAttempts) {
            const delay = RETRY_CONFIG.delays[attempt - 1] || 4000;
            console.error(JSON.stringify({
                warning: 'Transient error, retrying',
                attempt: attempt,
                maxAttempts: RETRY_CONFIG.maxAttempts,
                delayMs: delay,
                error: result.error?.message,
                timestamp: new Date().toISOString()
            }));
            await sleep(delay);
            continue;
        }

        // Non-transient error or max attempts reached
        break;
    }

    // Add retry information to final error
    if (lastResult && !lastResult.success) {
        lastResult.error.details = {
            ...lastResult.error.details,
            retryAttempts: RETRY_CONFIG.maxAttempts
        };
    }

    return lastResult;
}

/**
 * Maps error codes to exit codes per cli-contract.md
 *
 * @param {Object} errorResult - The error result from generateImage
 * @returns {number} Exit code
 */
function mapErrorToExitCode(errorResult) {
    const errorCode = errorResult.error?.error;

    switch (errorCode) {
        case 'CONFIG_ERROR':
            return ExitCodes.CONFIG_ERROR;
        case 'API_ERROR':
        case 'NETWORK_ERROR':
            return ExitCodes.API_ERROR;
        case 'FS_ERROR':
        case 'PARSE_ERROR':
            return ExitCodes.FS_ERROR;
        default:
            // Default to API_ERROR for unknown errors
            return ExitCodes.API_ERROR;
    }
}

/**
 * Parses CLI arguments into a map
 *
 * @param {string[]} args - Process arguments
 * @returns {Object} Parsed arguments map
 */
function parseArgs(args) {
    const argMap = {};
    for (let i = 0; i < args.length; i++) {
        const arg = args[i];
        if (arg.startsWith('--')) {
            const key = arg.replace(/^--/, '');
            const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true;
            argMap[key] = value;
            if (value !== true) i++;
        }
    }
    return argMap;
}

/**
 * Main execution function
 *
 * @returns {Promise<number>} Exit code
 */
async function main() {
    const args = process.argv.slice(2);
    const argMap = parseArgs(args);

    // Handle help flag
    if (argMap.help || argMap.h) {
        console.log('Image Generation CLI');
        console.log('');
        console.log('Usage: node generate.js [options]');
        console.log('');
        console.log('Options:');
        console.log('  --prompt <text>     Image generation prompt (required)');
        console.log('  --model <id>        Text-to-Image Model ID (env: IMAGE_GEN_TEXT_TO_IMAGE_MODEL, default: bytedance-seed/seedream-4.5)');
        console.log('  --i2i-model <id>    Image-to-Image Model ID (env: IMAGE_GEN_IMAGE_TO_IMAGE_MODEL, default: google/gemini-2.5-flash-image)');
        console.log('  --input-image <path> Input image file for image-to-image');
        console.log('  --size <1K|2K|4K>  Image resolution tier (default: model default)');
        console.log('  --aspect <ratio>    Aspect ratio (e.g., 1:1)');
        console.log('  --output <path>     Output file path (default: .sisyphus/generated/image_<timestamp>.png)');
        console.log('  --help              Show this help');
        console.log('');
        console.log('Exit Codes:');
        console.log('  0  SUCCESS');
        console.log('  1  CONFIG_ERROR (missing params, invalid flags, auth)');
        console.log('  2  API_ERROR (rate limits, server errors)');
        console.log('  3  FS_ERROR (filesystem issues)');
        console.log('');
        console.log('Environment Variables:');
        console.log('  OPENROUTER_API_KEY  Required API key for OpenRouter');
        console.log('');
        console.log('Examples:');
        console.log('  node generate.js --prompt "a sunset over mountains"');
        console.log('  node generate.js --prompt "a cat" --output ./images/cat.png');
        console.log('  node generate.js --prompt "a city" --size 2K --aspect 16:9');
        return ExitCodes.SUCCESS;
    }

    // Validate required parameters
    if (!argMap.prompt) {
        const error = createError(
            'CONFIG_ERROR',
            'The --prompt argument is required. Use --help for usage information.',
            400,
            { field: 'prompt' }
        );
        console.error(JSON.stringify(error, null, 2));
        return ExitCodes.CONFIG_ERROR;
    }

    const [REDACTED_SECRET];
    if (!apiKey) {
        const error = createError(
            'CONFIG_ERROR',
            'Missing OPENROUTER_API_KEY environment variable.',
            401
        );
        console.error(JSON.stringify(error, null, 2));
        return ExitCodes.CONFIG_ERROR;
    }

    // Determine output path
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
    const defaultOutput = path.join('.sisyphus', 'generated', `image_${timestamp}.png`);
    const outputPath = argMap.output || defaultOutput;

    // Validate output path safety
    const pathValidation = validateOutputPath(outputPath);
    if (!pathValidation.valid) {
        console.error(JSON.stringify(pathValidation.error, null, 2));
        return ExitCodes.CONFIG_ERROR;
    }

    const resolvedOutputPath = pathValidation.resolvedPath;

    // Ensure parent directory exists
    const dirResult = ensureDirectoryExists(resolvedOutputPath);
    if (!dirResult.success) {
        console.error(JSON.stringify(dirResult.error, null, 2));
        return ExitCodes.FS_ERROR;
    }

    // Generate image with retry logic
    const result = await generateImageWithRetry(
        apiKey,
        argMap['i2i-model'],
        argMap.model,
        argMap.prompt,
        argMap.size,
        argMap.aspect,
        argMap['input-image']
    );

    if (!result.success) {
        const exitCode = mapErrorToExitCode(result);
        console.error(JSON.stringify(result.error, null, 2));
        return exitCode;
    }

    // Extract and decode base64 image
    let imageData;
    try {
        const base64Data = result.data.imageUrl.replace(/^data:image\/\w+;base64,/, '');
        imageData = Buffer.from(base64Data, 'base64');
    } catch (error) {
        const fsError = createError(
            'FS_ERROR',
            `Failed to decode image data: ${error.message}`,
            500,
            { error: error.message }
        );
        console.error(JSON.stringify(fsError, null, 2));
        return ExitCodes.FS_ERROR;
    }

    // Write image file atomically
    const writeResult = writeFileAtomic(resolvedOutputPath, imageData);
    if (!writeResult.success) {
        console.error(JSON.stringify(writeResult.error, null, 2));
        return ExitCodes.FS_ERROR;
    }

    // Success output
    const successOutput = {
        success: true,
        data: {
            outputPath: resolvedOutputPath,
            model: result.data.model,
            usage: result.data.usage
        },
        message: 'Image generated successfully',
        timestamp: new Date().toISOString()
    };

    console.log(JSON.stringify(successOutput, null, 2));
    return ExitCodes.SUCCESS;
}

// Execute main function
main().then(exitCode => {
    process.exit(exitCode);
}).catch(error => {
    const unexpectedError = createError(
        'API_ERROR',
        `Unexpected error: ${error.message}`,
        500,
        { stack: error.stack }
    );
    console.error(JSON.stringify(unexpectedError, null, 2));
    process.exit(ExitCodes.API_ERROR);
});


path: references/configuration.md
content: # Configuration Specification

This document defines the environment variables, configuration defaults, and validation policy for the `image-generation` skill.

## 1. Configuration Precedence

The following order of precedence is applied when resolving configuration values:

1.  **CLI Arguments** (Highest priority)
2.  **Environment Variables**
3.  **Hardcoded Defaults** (Lowest priority)

## 2. Environment Variables

### Required
- `OPENROUTER_API_KEY`: The API key for OpenRouter. Without this, the skill will fail fast.

### Optional
- `IMAGE_GEN_TEXT_TO_IMAGE_MODEL`: The default text-to-image model.
    - Default value: `bytedance-seed/seedream-4.5`
- `IMAGE_GEN_IMAGE_TO_IMAGE_MODEL`: The default image-to-image model.
    - Default value: `google/gemini-2.5-flash-image`

## 3. Validation Policy

### Missing API Key
If `OPENROUTER_API_KEY` is not set, the skill must:
1.  Fail immediately (Fail Fast).
2.  Exit with a non-zero exit code.
3.  Provide a clear, actionable error message: `Missing OPENROUTER_API_KEY environment variable.`

### Missing Prompt
The `--prompt` CLI argument is mandatory. If missing:
1.  Fail immediately.
2.  Exit with a non-zero exit code.
3.  Provide a clear error message: `The --prompt argument is required.`

## 4. Default Model Mapping

| Source | Model ID |
| :--- | :--- |
| **Hardcoded Default (T2I)** | `bytedance-seed/seedream-4.5` |
| **Hardcoded Default (I2I)** | `google/gemini-2.5-flash-image` |
| **Provider** | OpenRouter |

---
*Note: Future providers will be documented in the [Extension Guide](./extension-guide.md).*


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