Skill: zoucdr/glass-cut-video (version 0.1.3)

--- name: glass-cut-video version: 1.0.0 description: "Generate satisfying vertical short videos of glass cutting and shattering (WeryAI): text-to-video or animate a glass photo into cuts, crack spread, and break motion for Douyin/Kuaishou-style ASMR. Use when you need glass cutting video, glass shatter clip, scratch-and-crack ASMR, or users ask for clean slices, crack propagation, or glass ASMR sound." tags: [asmr, satisfying, short-video, glass, vertical-video] metadata: { "openclaw": { "emoji": "🪟", "primaryEnv": "WERYAI_API_KEY", "paid": true, "network_required": true, "requires": { "env": ["WERYAI_API_KEY"], "bins": ["node"], "node": ">=18" } } } user-invocable: true --- # Glass cutting & shatter videos Built for satisfying-content creators. Whether it’s the perfection of a straight cut, the rush of tempered glass exploding, or stained glass glowing in the light—you can go from one line of text or one image to a ready-to-post **9:16** clip. **Dependencies:** `scripts/video_gen.js` in this folder + `WERYAI_API_KEY` in the environment + Node.js 18+. No other Cursor skills required. ## Prerequisites - `WERYAI_API_KEY` **must be set** in the environment before running `video_gen.js`. - Node.js **18+** is required. Image inputs **must** be public `https` URLs (no local file paths). - Each successful `wait` run consumes WeryAI credits; re-running creates new paid tasks. ## Security, secrets, and API hosts - **`WERYAI_API_KEY`**: Treat as a secret. Only configure it if you trust this skill's source; it is listed in OpenClaw metadata as **`requires.env`** / **`primaryEnv`** so installers know it is mandatory at runtime (never commit it inside the skill package). - **API hosts (fixed in `video_gen.js`)**: Video tasks use **`https://api.weryai.com`**; the models list uses **`https://api-growth-agent.weryai.com`**. Only **`WERYAI_API_KEY`** is read from the environment—do not rely on URL-related environment variables. - **Higher assurance**: Run generation in a short-lived or isolated environment (separate account or container), and review `scripts/video_gen.js` (HTTPS submit + poll loop) before production use. ## Prompt expansion (mandatory) `video_gen.js` does **not** expand prompts. Before every `wait --json`, turn the user's short or vague brief into a **full English production `prompt`**. **When:** The user gives only keywords, one line, or loose intent—or asks for richer video language. **Exception:** They paste a finished long prompt within the model's `prompt_length_limit` and ask you **not** to rewrite; still show the **full** text in the confirmation table. **Always add (video language):** shot scale and angle; camera move or lock-off; light quality and motivation; subject action paced to `duration`; **one clear payoff** for this niche; state **9:16 vertical** when this skill defaults to vertical. **Length:** Obey `prompt_length_limit` for the chosen `model_key` when this doc lists it; trim filler adjectives before removing core action, lens, or light clauses. **Confirmation:** The pre-submit table **must** include the **full expanded `prompt`** (never a one-line summary). Wait for **confirm** or edits. ### Niche checklist - **Glass grammar:** score line, crack propagation, snap, shards, tempered burst, or stained-glass glow—pick one clear beat. - **Macro & light:** caustics, speculars, transmitted color; **ASMR** glass tone if audio on. - **Safety tone:** stylized satisfying break, not real injury context. **`### Example prompts`** at the top of this file are **short triggers only**—always expand from the user's actual request. ## Workflow 1. Confirm the request matches this skill (text-to-video and/or image-to-video as documented). 2. Collect the user's **brief**, optional image URL(s), tier (**best** / **good** / **fast**) or an explicit `model` key. 3. **Expand prompt (mandatory):** Unless the user supplied a finished long prompt and explicitly asked not to rewrite it, expand the brief into a full English production `prompt` using `## Prompt expansion (mandatory)` below. **Do not** call the API with only the user's minimal words. 4. Check the **expanded** `prompt` against the selected model's `prompt_length_limit` in the frozen tables in this document (when present); shorten if needed. 5. Verify `duration`, `aspect_ratio`, `resolution`, `generate_audio`, `negative_prompt`, and other fields against the frozen tables and API notes in this SKILL.md. 6. Show the pre-submit parameter table including the **full expanded `prompt`**; wait for **confirm** or edits. 7. After confirmation, run `node {baseDir}/scripts/video_gen.js wait --json '...'` with the **expanded** prompt. 8. Parse stdout JSON and return video URLs; on failure, surface `errorCode` / `errorMessage` and suggest parameter fixes. ## CLI reference ```sh node {baseDir}/scripts/video_gen.js wait --json '{"model":"…","prompt":"…","duration":5,"aspect_ratio":"9:16"}' node {baseDir}/scripts/video_gen.js wait --json '…' --dry-run node {baseDir}/scripts/video_gen.js status --task-id <id> ``` ## Definition of done Done when the user receives at least one playable video URL from the API response, or a clear failure explanation with next steps. All parameters used **must** fall within the selected model’s allowed sets in this document. The submitted `prompt` **must** be the **expanded** production prompt unless the user explicitly supplied a finished long prompt and asked not to rewrite it. ## Boundaries (out of scope) - We do not review platform compliance, copyright, or likeness; we do not warrant commercial usability of outputs. - We do not provide offline rendering, traditional NLE projects, or API field combinations not documented here. - Do not hard-code absolute paths in this doc; `{baseDir}` is this skill root (next to `SKILL.md`). ### Example prompts - `Vertical clip: cutting thick glass, satisfying sound, crack spreading from one point` - `This image is a windowpane—animate a blade scratch and cracks growing in the reflection` - `Satisfying glass ASMR 9:16, clean slice, translucent look, not gory` - `Glass cutting ASMR 9:16, slow crack propagation, clean slice reveal` --- ## Default parameters | Field | Value | |-------|--------| | Model | KLING_V3_0_PRO | | Aspect | 9:16 (fixed, vertical short video) | | Duration | 5 s | | Resolution | — (`KLING_V3_0_PRO` has no `resolution` field—do not send) | | Audio | On (auto glass cut / shatter sounds) | | Look | Extreme macro, cold white light, slow motion, minimal background, translucency first | > **API validity (default `KLING_V3_0_PRO`):** Text-to-video: `duration` only **5 / 10 / 15**, `aspect_ratio` only **9:16, 1:1, 16:9**; image-to-video: `aspect_ratio` only **9:16, 16:9, 1:1**; **no `resolution` field—do not send.** Fast VEO tier: text **`VEO_3_1_FAST`**, image **`CHATBOT_VEO_3_1_FAST`**, `duration` **fixed 8**, `aspect_ratio` only **9:16** or **16:9**. For other `model_key` values, follow the allowed sets in this document; do not send unsupported fields. --- ## Text-to-video: cutting glass The user describes glass type and how it breaks; you generate the clip. Good for batch ideation and hook testing. **User should provide:** - Glass type (clear / stained / tempered / frosted / laminated) - Cut or break style (straight score-and-snap / irregular cut / slow shatter / full burst / crack spread) - Optional: angle or detail (“shards fly”, “side light on refraction”) **Flow:** 1. Collect glass type and break style; ask if missing. 2. Build an English prompt emphasizing cut marks, refraction, and break motion. 3. After confirmation, run (`{baseDir}` = this skill root): ```sh node {baseDir}/scripts/video_gen.js wait --json '{"model":"KLING_V3_0_PRO","prompt":"(full English prompt)","aspect_ratio":"9:16","duration":5,"generate_audio":true}' ``` Match JSON to the confirmed table; add `resolution` only if the model supports it. Parse `videos` from stdout. 4. Return URLs and what to tweak next. **Parameters:** | Field | Value | |-------|--------| | model | KLING_V3_0_PRO | | aspect_ratio | 9:16 | | duration | 5 | | generate_audio | true | **Sample prompt (stained glass straight cut):** > Extreme close-up macro shot of vibrant stained glass being scored and snapped cleanly along a straight line, cold white backlighting makes colors saturate brilliantly, the crack propagates with satisfying precision, glass edge perfectly smooth, slow motion 240fps, dust motes caught in light beam, ASMR cutting sound, minimal white background, shallow depth of field **Sample prompt (tempered shatter):** > Ultra close-up of tempered glass shattering into thousands of tiny cubed fragments in extreme slow motion, cold studio lighting catches every spinning shard, crystalline transparency, satisfying explosive burst contained in frame, glass pebbles cascade like water, cinematic depth of field, ASMR crunch and tinkle **Expected result:** 5 s vertical clip with coherent cut/shatter motion, readable glass texture and light, synced break sounds, high completion rate. --- ## Image-to-video: glass motion User supplies a glass image URL; you add crack spread or cut motion on top of that look. **User should provide:** - Image URL (public `https` only) - Desired motion (crack from center / blade scratch clean line / slow full break / edge chip) **Flow:** 1. Validate `https://` URL (not a local path). 2. Build motion prompt anchored to the image’s glass material. 3. After confirmation: ```sh node {baseDir}/scripts/video_gen.js wait --json '{"model":"KLING_V3_0_PRO","prompt":"(full English prompt)","image":"(user HTTPS URL)","aspect_ratio":"9:16","duration":5,"generate_audio":true}' ``` 4. Return URLs. **Parameters:** | Field | Value | |-------|--------| | model | KLING_V3_0_PRO | | aspect_ratio | 9:16 | | duration | 5 | | generate_audio | true | | image | User’s image URL | **Sample prompt (crack spread):** > The glass surface in the image begins to crack, a single hairline fracture propagates outward in extreme slow motion, branching into a web of perfect cracks, cold white light refracts through each fracture line creating rainbow caustics, satisfying tension-release moment, ASMR glass stress sound **Expected result:** Motion matches the uploaded glass; strong refraction; consistent with the source image. --- ## Prompt building blocks **Brittle snap:** `satisfying snap`, `perfect clean break`, `zero resistance`, `ultra-brittle` **Clean edge:** `laser-precise cut line`, `mirror-smooth edge`, `factory perfect`, `atomic-level clean slice` **Clarity:** `crystal clear transparency`, `light refracts into spectrum`, `backlit caustics`, `liquid-like clarity` **Safe tension:** `controlled shatter`, `contained explosion`, `safe distance macro`, `slow motion danger` **Slow-mo:** `240fps extreme slow motion`, `time dilation effect`, `every shard frozen mid-air` > **Note:** Image URLs must be publicly reachable over HTTPS or the API will fail. Upload to a host first if needed.

path: eval.yaml
content: version: "1"
defaults:
  trials: 3
  timeout: 600
  threshold: 0.75
skill: glass-cut-video
trigger_tests:
  - id: explicit-invoke
    should_trigger: true
    prompt: "Use the $glass-cut-video skill to create a sample vertical video."
  - id: implicit-domain
    should_trigger: true
    prompt: "做一段竖屏切厚玻璃的解压视频，要切割声和裂纹从一点扩散"
  - id: negative-control
    should_trigger: false
    prompt: "Refactor my React dashboard and add unit tests."
outcome_tests:
  - id: skill-package-layout
    type: deterministic
    checks:
      - description: "SKILL.md exists with name matching folder slug"
      - file_exists: "SKILL.md"
      - file_exists: "scripts/video_gen.js"
  - id: optional-quality
    type: llm_rubric
    rubric: |
      Does the assistant stay within WeryAI video_gen constraints (model, duration, aspect_ratio)?
      Does it refuse local image paths and ask for https URLs?


path: scripts/video_gen.js
content: #!/usr/bin/env node
/**
 * WeryAI 视频生成 — 通用 CLI（零 npm 依赖，需 Node.js 18+）
 *
 * 命令:
 *   wait | submit-text | submit-image | submit-multi-image | status | models
 *
 * 示例:
 *   node video_gen.js models
 *   node video_gen.js models --mode text_to_video
 *   node video_gen.js wait --json '{"model":"WERYAI_VIDEO_1_0","prompt":"...","duration":5}'
 *   node video_gen.js wait --json '...' --dry-run
 *
 * 环境变量:
 *   WERYAI_API_KEY（models / 生成 / status 必填；--dry-run 除外）— 敏感凭据，勿写入仓库；registry 元数据声明见同目录 SKILL.md。
 *   本脚本仅读取上述密钥；API 主机与轮询间隔为固定常量，勿用其他 env 覆盖。
 */

const DEFAULT_MODEL = 'WERYAI_VIDEO_1_0';
const BASE_URL = 'https://api.weryai.com';
const MODELS_BASE_URL = 'https://api-growth-agent.weryai.com';
const MODELS_API_PATH = '/growthai/v1/video/models';
const POLL_INTERVAL_MS = 6000;
const POLL_TIMEOUT_MS = 600000;

const STATUS_MAP = {
  waiting: 'waiting',
  WAITING: 'waiting',
  pending: 'waiting',
  PENDING: 'waiting',
  processing: 'processing',
  PROCESSING: 'processing',
  succeed: 'completed',
  SUCCEED: 'completed',
  success: 'completed',
  SUCCESS: 'completed',
  failed: 'failed',
  FAILED: 'failed',
};

const ERROR_MESSAGES = {
  400: 'Bad request — check your request parameters.',
  403: 'Invalid API key or IP access denied — verify WERYAI_API_KEY is correct.',
  500: 'WeryAI server error. Please try again later.',
  1001: 'Request rate limit exceeded — slow down and retry after a moment.',
  1002: 'Parameter error — check prompt, image URLs, aspect_ratio, model key, and duration.',
  1003: 'Resource not found — the task_id may not exist or has expired.',
  1006: 'Model not supported — check the model key or try a different model.',
  1007: 'Queue full — the service is busy, please try again later.',
  1011: 'Insufficient credits — recharge at weryai.com.',
  2003: 'Content flagged by safety system — revise your prompt or input image.',
  2004: 'Image format not supported — use jpg, png, or webp.',
  6004: 'Generation failed — please try again later.',
  6010: 'Concurrent task limit reached (max 20) — wait for existing tasks to complete.',
};

function log(msg) {
  process.stderr.write(`[weryai] ${msg}\n`);
}

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function httpJson(method, fullUrl, body, apiKey) {
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  };
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 30_000);
  try {
    const res = await fetch(fullUrl, {
      method,
      headers,
      body: body != null ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });
    clearTimeout(timer);
    let data;
    try {
      data = await res.json();
    } catch {
      data = { status: res.status, msg: `Non-JSON response (HTTP ${res.status})` };
    }
    return { httpStatus: res.status, ...data };
  } catch (err) {
    clearTimeout(timer);
    if (err.name === 'AbortError') {
      throw new Error(`Request timeout: ${method} ${fullUrl}`);
    }
    throw err;
  }
}

async function apiRequest(method, path, body, apiKey) {
  return httpJson(method, BASE_URL + path, body, apiKey);
}

async function fetchModelsRegistry(apiKey) {
  return httpJson('GET', MODELS_BASE_URL + MODELS_API_PATH, null, apiKey);
}

function isApiSuccess(res) {
  const httpOk = res.httpStatus >= 200 && res.httpStatus < 300;
  const bodyOk = res.status === 0 || res.status === 200;
  return httpOk && bodyOk;
}

function formatApiError(res) {
  const httpStatus = res.httpStatus || 0;
  const code = res.status;
  const msg = res.msg || '';

  if (httpStatus === 403) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '403',
      errorMessage: ERROR_MESSAGES[403] + (msg ? ` (${msg})` : ''),
    };
  }
  if (httpStatus === 429) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'RATE_LIMIT',
      errorMessage: 'Rate limited by WeryAI API. Please wait and try again.',
    };
  }
  if (httpStatus >= 500) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '500',
      errorMessage: `${ERROR_MESSAGES[500]} (HTTP ${httpStatus})`,
    };
  }
  if (httpStatus === 400) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '400',
      errorMessage: ERROR_MESSAGES[400] + (msg ? ` (${msg})` : ''),
    };
  }

  const friendly = ERROR_MESSAGES[code] || '';
  const message =
    friendly && msg
      ? `${friendly} (${msg})`
      : friendly || msg || `API error (status: ${code}, HTTP ${httpStatus})`;

  return {
    ok: false,
    phase: 'failed',
    errorCode: code != null ? String(code) : null,
    errorMessage: message,
  };
}

function detectMode(params) {
  if (Array.isArray(params.images) && params.images.length > 0) return 'multi_image';
  if (typeof params.image === 'string' && params.image) return 'image';
  return 'text';
}

function coerceBool(v, defaultVal = false) {
  if (v == null) return defaultVal;
  if (typeof v === 'boolean') return v;
  return String(v).toLowerCase() === 'true';
}

function buildRequestBody(params) {
  const model = params.model || DEFAULT_MODEL;
  const body = {
    prompt: params.prompt,
    model,
    duration: Number(params.duration) || 5,
  };
  if (params.aspect_ratio) body.aspect_ratio = params.aspect_ratio;
  if (params.resolution) body.resolution = params.resolution;
  if (params.generate_audio != null) body.generate_audio = coerceBool(params.generate_audio, false);
  if (params.negative_prompt) body.negative_prompt = params.negative_prompt;
  if (params.image) body.image = params.image;
  if (params.images) body.images = params.images.slice(0, 3);
  return body;
}

async function submitTask(params, apiKey) {
  const mode = detectMode(params);
  const pathMap = {
    text: '/v1/generation/text-to-video',
    image: '/v1/generation/image-to-video',
    multi_image: '/v1/generation/multi-image-to-video',
  };
  const body = buildRequestBody(params);
  let res;
  try {
    res = await apiRequest('POST', pathMap[mode], body, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const data = res.data || {};
  const taskIds = data.task_ids ?? (data.task_id ? [data.task_id] : []);
  return {
    ok: true,
    phase: 'submitted',
    batchId: data.batch_id ?? null,
    taskIds,
    taskId: taskIds[0] ?? null,
    taskStatus: null,
    videos: null,
    errorCode: null,
    errorMessage: null,
  };
}

function extractVideos(taskData) {
  const tr = taskData.task_result || {};
  const raw = tr.videos || taskData.videos || [];
  return raw.map((v) => {
    if (typeof v === 'string') {
      return { url: v, cover_url: '' };
    }
    return {
      url: v?.url || v?.video_url || '',
      cover_url: v?.cover_image_url || v?.cover_url || '',
    };
  });
}

async function pollUntilDone(taskId, batchId, taskIds, apiKey) {
  const start = Date.now();
  while (true) {
    const elapsed = (Date.now() - start) / 1000;
    if (elapsed * 1000 >= POLL_TIMEOUT_MS) {
      return {
        ok: false,
        phase: 'failed',
        errorCode: 'TIMEOUT',
        errorMessage: `Poll timeout after ${Math.floor(elapsed)}s.`,
        batchId,
        taskIds,
        taskId,
        taskStatus: 'unknown',
        videos: null,
      };
    }

    await sleep(POLL_INTERVAL_MS);

    let res;
    try {
      res = await apiRequest('GET', `/v1/generation/${taskId}/status`, null, apiKey);
    } catch (err) {
      log(`Warning: poll failed (${err.message}), retrying...`);
      continue;
    }

    if (!isApiSuccess(res)) {
      log('Warning: poll returned non-success status, retrying...');
      continue;
    }

    const taskData = res.data || {};
    const rawStatus = taskData.task_status || '';
    const normalized = STATUS_MAP[rawStatus] || 'unknown';
    const elapsedSec = Math.floor((Date.now() - start) / 1000);
    log(`Polling ${taskId}... status: ${rawStatus} (${elapsedSec}s elapsed)`);

    if (normalized === 'completed') {
      const videos = extractVideos(taskData);
      return {
        ok: true,
        phase: 'completed',
        batchId,
        taskIds,
        taskId,
        taskStatus: rawStatus,
        videos: videos.length ? videos : null,
        errorCode: null,
        errorMessage: null,
      };
    }

    if (normalized === 'failed') {
      const tr = taskData.task_result || {};
      const errMsg = tr.message || taskData.msg || 'Task failed';
      return {
        ok: false,
        phase: 'failed',
        batchId,
        taskIds,
        taskId,
        taskStatus: rawStatus,
        videos: null,
        errorCode: 'TASK_FAILED',
        errorMessage: errMsg,
      };
    }
  }
}

async function cmdWait(params, apiKey) {
  const submitResult = await submitTask(params, apiKey);
  if (!submitResult.ok) return submitResult;
  const taskId = submitResult.taskId;
  const batchId = submitResult.batchId;
  const taskIds = submitResult.taskIds || [taskId];
  log(`Submitted task ${taskId}. Polling for completion...`);
  return pollUntilDone(taskId, batchId, taskIds, apiKey);
}

async function cmdStatus(taskId, apiKey) {
  let res;
  try {
    res = await apiRequest('GET', `/v1/generation/${taskId}/status`, null, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const taskData = res.data || {};
  const rawStatus = taskData.task_status || '';
  const normalized = STATUS_MAP[rawStatus] || 'unknown';
  const videos = extractVideos(taskData);
  const phase =
    normalized === 'completed' ? 'completed' : normalized === 'failed' ? 'failed' : 'running';
  const tr = taskData.task_result || {};
  return {
    ok: phase !== 'failed',
    phase,
    batchId: null,
    taskIds: [taskId],
    taskId,
    taskStatus: rawStatus,
    videos: videos.length ? videos : null,
    errorCode: phase === 'failed' ? 'TASK_FAILED' : null,
    errorMessage: phase === 'failed' ? tr.message || taskData.msg || null : null,
  };
}

const VALID_MODEL_MODES = ['text_to_video', 'image_to_video', 'multi_image_to_video'];

async function cmdModels(modeFilter, apiKey) {
  let res;
  try {
    res = await fetchModelsRegistry(apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const data = res.data || {};
  const out = { ok: true, phase: 'completed' };

  if (modeFilter) {
    if (!VALID_MODEL_MODES.includes(modeFilter)) {
      return {
        ok: false,
        phase: 'failed',
        errorCode: 'VALIDATION',
        errorMessage: `Invalid --mode. Use: ${VALID_MODEL_MODES.join(', ')}`,
      };
    }
    out[modeFilter] = data[modeFilter] || [];
  } else {
    out.text_to_video = data.text_to_video || [];
    out.image_to_video = data.image_to_video || [];
    out.multi_image_to_video = data.multi_image_to_video || [];
  }

  return out;
}

function parseArgs(argv) {
  const cmd = argv[0];
  let jsonStr = null;
  let taskId = null;
  let dryRun = false;
  let modelsMode = null;
  for (let i = 1; i < argv.length; i++) {
    if (argv[i] === '--json') jsonStr = argv[++i];
    else if (argv[i] === '--task-id') taskId = argv[++i];
    else if (argv[i] === '--dry-run') dryRun = true;
    else if (argv[i] === '--mode') modelsMode = argv[++i];
  }
  return { command: cmd, jsonStr, taskId, dryRun, modelsMode };
}

function printJson(obj) {
  process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}

async function main() {
  const argv = process.argv.slice(2);
  const valid = new Set([
    'wait',
    'submit-text',
    'submit-image',
    'submit-multi-image',
    'status',
    'models',
  ]);
  const { command, jsonStr, taskId: cliTaskId, dryRun, modelsMode } = parseArgs(argv);

  if (!command || !valid.has(command)) {
    printJson({
      ok: false,
      phase: 'failed',
      errorCode: 'USAGE',
      errorMessage:
        'Usage: node video_gen.js <wait|submit-text|submit-image|submit-multi-image|status|models> [--json \'...\'] [--task-id id] [--dry-run] [--mode text_to_video|image_to_video|multi_image_to_video]',
    });
    process.exit(1);
  }

  let params = {};
  if (jsonStr) {
    try {
      params = JSON.parse(jsonStr);
    } catch (e) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'INVALID_JSON',
        errorMessage: `Invalid JSON: ${e.message}`,
      });
      process.exit(1);
    }
  }

  if (command === 'models') {
    const [REDACTED_SECRET] || '').trim();
    if (!apiKey) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'NO_API_KEY',
        errorMessage: 'Missing WERYAI_API_KEY environment variable.',
      });
      process.exit(1);
    }
    const result = await cmdModels(modelsMode, apiKey);
    printJson(result);
    process.exit(result.ok ? 0 : 1);
  }

  if (dryRun) {
    const mode = detectMode(params);
    const pathMap = {
      text: '/v1/generation/text-to-video',
      image: '/v1/generation/image-to-video',
      multi_image: '/v1/generation/multi-image-to-video',
    };
    const body = 'prompt' in params ? buildRequestBody(params) : {};
    printJson({
      ok: true,
      phase: 'dry-run',
      dryRun: true,
      requestBody: body,
      requestUrl: BASE_URL + (pathMap[mode] || pathMap.text),
    });
    process.exit(0);
  }

  const [REDACTED_SECRET] || '').trim();
  if (!apiKey) {
    printJson({
      ok: false,
      phase: 'failed',
      errorCode: 'NO_API_KEY',
      errorMessage: 'Missing WERYAI_API_KEY environment variable.',
    });
    process.exit(1);
  }

  let result;
  if (command === 'status') {
    const tid = cliTaskId || params.task_id || params.taskId;
    if (!tid) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: 'status command requires --task-id <id>',
      });
      process.exit(1);
    }
    result = await cmdStatus(tid, apiKey);
  } else {
    if (!params.prompt) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: "'prompt' is required in JSON parameters.",
      });
      process.exit(1);
    }
    result = command === 'wait' ? await cmdWait(params, apiKey) : await submitTask(params, apiKey);
  }

  printJson(result);
  process.exit(result.ok ? 0 : 1);
}

main().catch((err) => {
  printJson({
    ok: false,
    phase: 'failed',
    errorCode: 'FATAL',
    errorMessage: err.message || String(err),
  });
  process.exit(1);
});


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