focalapi-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-08-18
4
+
5
+ - Added `task status --wait [--download]`: built-in bounded polling with elapsed-time reporting and an optional auto-download, so agents never need to write their own poller (the audited agent session's custom script crashed on the Windows `.cmd` shim and stalled blind).
6
+ - Added `task list [--status] [--action] [--limit] [--offset]` backed by `GET /v1/tasks`, so a caller that lost a submission output can reconcile recent tasks instead of resubmitting and double-charging.
7
+ - Added `--idempotency-key` to `gen video` and async `gen image`: same-key retries replay the original task server-side without a second charge. Keys are auto-generated when omitted, breadcrumbed on stderr (`idempotency_key=...`), and the server's `idempotent_replay` marker is surfaced in both JSON output and stderr.
8
+ - Aligned `task download --json` output with `gen image` by exposing `files[]` alongside the legacy `file`.
9
+ - Added a stderr warning for double-encoded reference URLs (`%25XX`) before submission — the top cause of 403 `invalid_reference_url` from presigned URLs.
10
+
11
+ ## 0.3.1 - 2026-08-18
12
+
13
+ - Added `--duration` as an alias of `--seconds` on `gen video` so the API contract field name works directly; conflicting values are rejected locally.
14
+ - Emitted a stderr breadcrumb (`task_id=...`) for every `--no-wait` submission so callers that fail to parse stdout JSON can recover the task instead of resubmitting and double-charging.
15
+ - Added case-sensitive ID transcription guidance to `task status`/`task cancel` 404 errors (`l`/`1`/`I`, `O`/`0`) with an explicit warning against blind resubmission.
16
+ - Updated the bundled Agent Skills with resubmission discipline, the Windows `.cmd` shim invocation requirement, and exact task-ID copying guidance.
17
+
3
18
  ## 0.3.0 - 2026-08-18
4
19
 
5
20
  - Added `task cancel` for queued tasks through `DELETE /v1/video/generations/{task_id}`, including the 409 `task_already_running` / `task_already_finished` and 502 `task_cancel_failed` contract with actionable hints.
package/dist/cli.js CHANGED
@@ -176,7 +176,7 @@ function displayWidth(s) {
176
176
  }
177
177
 
178
178
  // src/lib/version.ts
179
- var VERSION = true ? "0.3.0" : "0.0.0-dev";
179
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
180
180
 
181
181
  // src/commands/auth.ts
182
182
  import { createInterface } from "readline/promises";
@@ -916,6 +916,7 @@ import { mkdir as mkdir2, writeFile } from "fs/promises";
916
916
  import { join as join3, resolve as resolve2 } from "path";
917
917
  import { pipeline as pipeline2 } from "stream/promises";
918
918
  import { Readable as Readable2 } from "stream";
919
+ import { randomUUID } from "crypto";
919
920
 
920
921
  // src/lib/model-capabilities.ts
921
922
  var GEMINI_IMAGE_MAX_SEED = 9007199254740991;
@@ -1526,6 +1527,19 @@ function extractProgress(body) {
1526
1527
  }
1527
1528
  return void 0;
1528
1529
  }
1530
+ function extractCreatedAt(raw) {
1531
+ if (!raw || typeof raw !== "object") return void 0;
1532
+ const obj = raw;
1533
+ const candidates = [obj.created_at, obj.data?.created_at];
1534
+ for (const candidate of candidates) {
1535
+ if (typeof candidate === "number" && candidate > 0) return candidate;
1536
+ if (typeof candidate === "string") {
1537
+ const parsed = Number.parseInt(candidate, 10);
1538
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
1539
+ }
1540
+ }
1541
+ return void 0;
1542
+ }
1529
1543
  async function cancelTask(baseUrl, apiKey, taskId) {
1530
1544
  try {
1531
1545
  await request({
@@ -1549,6 +1563,12 @@ async function cancelTask(baseUrl, apiKey, taskId) {
1549
1563
  hint: "\u8FD0\u884C focalapi task status " + taskId + " \u67E5\u770B\u7ED3\u679C\uFF1B\u6210\u529F\u540E\u53EF\u4E0B\u8F7D\u4EA7\u7269\u3002"
1550
1564
  });
1551
1565
  }
1566
+ if (code === "task_not_found") {
1567
+ throw new ApiError("task_not_found", `\u4EFB\u52A1 ${taskId} \u4E0D\u5B58\u5728\u6216\u4E0D\u5C5E\u4E8E\u5F53\u524D Key`, {
1568
+ status: err.status,
1569
+ hint: "task_id \u533A\u5206\u5927\u5C0F\u5199\u4E14\u6DF7\u6709\u5C0F\u5199 l / \u6570\u5B57 1 / \u5927\u5199 I\uFF0C\u5FC5\u987B\u9010\u5B57\u590D\u5236\uFF1B\u8BF7\u56DE\u67E5\u63D0\u4EA4\u8F93\u51FA\u540E\u518D\u8BD5\u3002"
1570
+ });
1571
+ }
1552
1572
  if (code === "task_cancel_incomplete") {
1553
1573
  throw new ApiError("task_cancel_incomplete", `\u4EFB\u52A1 ${taskId} \u5DF2\u53D6\u6D88\u4F46\u6E05\u7406\u672A\u5B8C\u6210`, {
1554
1574
  status: err.status,
@@ -1582,9 +1602,24 @@ async function fetchTask(baseUrl, apiKey, taskId) {
1582
1602
  status: normalizeTaskStatus(rawStatus),
1583
1603
  rawStatus,
1584
1604
  progress: extractProgress(raw),
1605
+ createdAt: extractCreatedAt(raw),
1585
1606
  raw
1586
1607
  };
1587
1608
  }
1609
+ async function listTasks(baseUrl, apiKey, opts) {
1610
+ const raw = await request({
1611
+ baseUrl,
1612
+ path: "/v1/tasks",
1613
+ apiKey,
1614
+ query: {
1615
+ status: opts?.status,
1616
+ action: opts?.action,
1617
+ limit: opts?.limit,
1618
+ offset: opts?.offset
1619
+ }
1620
+ });
1621
+ return raw.data ?? [];
1622
+ }
1588
1623
  async function pollTask(baseUrl, apiKey, taskId, opts) {
1589
1624
  const intervalMs = opts?.intervalMs ?? 5e3;
1590
1625
  const timeoutMs = opts?.timeoutMs ?? 30 * 6e4;
@@ -1609,7 +1644,7 @@ async function pollTask(baseUrl, apiKey, taskId, opts) {
1609
1644
  }
1610
1645
  if (Date.now() > deadline) {
1611
1646
  throw new ApiError("timeout", `\u4EFB\u52A1 ${taskId} \u7B49\u5F85\u8D85\u65F6\uFF08${Math.round(timeoutMs / 6e4)} \u5206\u949F\uFF09`, {
1612
- hint: `\u53EF\u7A0D\u540E\u8FD0\u884C focalapi task status ${taskId} \u67E5\u770B\uFF0C\u6216 focalapi task download ${taskId} \u7EED\u53D6\u4EA7\u7269\u3002`
1647
+ hint: `\u53EF\u8FD0\u884C focalapi task status ${taskId} --wait \u7EE7\u7EED\u7B49\u5F85\uFF0C\u6216 focalapi task status ${taskId} \u67E5\u770B\u5F53\u524D\u72B6\u6001\u3002`
1613
1648
  });
1614
1649
  }
1615
1650
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -1647,6 +1682,20 @@ async function downloadTaskContent(baseUrl, apiKey, taskId, outDir, filenameBase
1647
1682
  var MAX_IMAGE_N = 128;
1648
1683
  var MAX_TASK_DURATION_SECONDS = 3600;
1649
1684
  var DEFAULT_OUT_DIR = "focalapi-out";
1685
+ function resolveIdempotencyKey(provided) {
1686
+ const key = provided?.trim() || randomUUID();
1687
+ if (!/^[\x21-\x7e]{8,128}$/.test(key)) {
1688
+ throw new ApiError("invalid_request", "--idempotency-key \u5FC5\u987B\u662F 8\u2013128 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF08\u6536\u5230\uFF1A" + provided + "\uFF09");
1689
+ }
1690
+ return key;
1691
+ }
1692
+ function warnDoubleEncodedReferenceURLs(label, urls) {
1693
+ for (const url of urls) {
1694
+ if (url && /%25[0-9a-f]{2}/i.test(url)) {
1695
+ info(`\u8B66\u544A\uFF1A${label} \u7684 URL \u7591\u4F3C\u88AB\u4E8C\u6B21\u7F16\u7801\uFF08\u5305\u542B %25XX\uFF09\uFF1A${url.slice(0, 100)} \u2014\u2014 \u9884\u7B7E\u540D URL \u5FC5\u987B\u539F\u6837\u4F20\u9012\uFF0C\u5426\u5219\u4E0A\u6E38\u4F1A\u8FD4\u56DE 403 invalid_reference_url`);
1696
+ }
1697
+ }
1698
+ }
1650
1699
  function clampInt(value, min, max, name) {
1651
1700
  if (!Number.isInteger(value) || value < min || value > max) {
1652
1701
  throw new ApiError("invalid_request", `${name} \u5FC5\u987B\u662F ${min}\u2013${max} \u7684\u6574\u6570\uFF08\u6536\u5230\uFF1A${value}\uFF09`);
@@ -1763,7 +1812,7 @@ function extractGeminiImageItems(response) {
1763
1812
  }
1764
1813
  function registerGen(program) {
1765
1814
  const gen = program.command("gen").description("\u56FE\u50CF / \u89C6\u9891\u751F\u6210");
1766
- gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9").option("--resolution <resolution>", "\u6A21\u578B\u539F\u751F\u8F93\u51FA\u6863\u4F4D\uFF0C\u5982 1k\u30012k").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--quality <quality>", "\u56FE\u50CF\u8D28\u91CF\u6863\u4F4D\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--background <background>", "\u80CC\u666F\u6A21\u5F0F\uFF08\u4EC5 gpt-image-2 \u652F\u6301 auto/opaque\uFF09").option("--negative-prompt <text>", "\u8D1F\u9762\u63D0\u793A\u8BCD\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--creativity <level>", "\u63D0\u793A\u8BCD\u6269\u5C55\u5F3A\u5EA6\uFF0C\u5982 raw\u3001low\u3001medium\u3001high").option("--prompt-extend <boolean>", "\u662F\u5426\u6269\u5C55\u63D0\u793A\u8BCD\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "prompt-extend")).option("--style-references <json>", "Krea image_style_references JSON \u6570\u7EC4").option("--moodboards <json>", "Krea moodboards JSON \u6570\u7EC4").option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--output-format <format>", "\u8F93\u51FA\u683C\u5F0F\uFF08\u4EC5 Seedream\uFF1Apng \u6216 jpeg\uFF09").option("--optimize-prompt <mode>", "\u63D0\u793A\u8BCD\u4F18\u5316\uFF08\u4EC5 Seedream\uFF1Aauto\u3001enabled \u6216 disabled\uFF09").option("--image <url...>", "\u53C2\u8003\u56FE\u6216\u7F16\u8F91\u56FE URL\uFF0C\u53EF\u591A\u4E2A").option("--mask <url>", "\u7F16\u8F91 mask URL\uFF08gpt-image-2 \u9700\u8981\u5355\u5F20\u53C2\u8003\u56FE\uFF09").option("--response-format <format>", "\u56FE\u50CF\u54CD\u5E94\u683C\u5F0F\uFF1Aurl \u6216 b64_json").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u56FE\u50CF\u751F\u6210\u5B8C\u6210").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
1815
+ gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9").option("--resolution <resolution>", "\u6A21\u578B\u539F\u751F\u8F93\u51FA\u6863\u4F4D\uFF0C\u5982 1k\u30012k").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--quality <quality>", "\u56FE\u50CF\u8D28\u91CF\u6863\u4F4D\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--background <background>", "\u80CC\u666F\u6A21\u5F0F\uFF08\u4EC5 gpt-image-2 \u652F\u6301 auto/opaque\uFF09").option("--negative-prompt <text>", "\u8D1F\u9762\u63D0\u793A\u8BCD\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--creativity <level>", "\u63D0\u793A\u8BCD\u6269\u5C55\u5F3A\u5EA6\uFF0C\u5982 raw\u3001low\u3001medium\u3001high").option("--prompt-extend <boolean>", "\u662F\u5426\u6269\u5C55\u63D0\u793A\u8BCD\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "prompt-extend")).option("--style-references <json>", "Krea image_style_references JSON \u6570\u7EC4").option("--moodboards <json>", "Krea moodboards JSON \u6570\u7EC4").option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--output-format <format>", "\u8F93\u51FA\u683C\u5F0F\uFF08\u4EC5 Seedream\uFF1Apng \u6216 jpeg\uFF09").option("--optimize-prompt <mode>", "\u63D0\u793A\u8BCD\u4F18\u5316\uFF08\u4EC5 Seedream\uFF1Aauto\u3001enabled \u6216 disabled\uFF09").option("--image <url...>", "\u53C2\u8003\u56FE\u6216\u7F16\u8F91\u56FE URL\uFF0C\u53EF\u591A\u4E2A").option("--mask <url>", "\u7F16\u8F91 mask URL\uFF08gpt-image-2 \u9700\u8981\u5355\u5F20\u53C2\u8003\u56FE\uFF09").option("--response-format <format>", "\u56FE\u50CF\u54CD\u5E94\u683C\u5F0F\uFF1Aurl \u6216 b64_json").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u56FE\u50CF\u751F\u6210\u5B8C\u6210").option("--idempotency-key <key>", "\u5E42\u7B49\u952E\uFF088\u2013128 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09\u3002\u540C\u4E00 key \u7684\u91CD\u590D\u63D0\u4EA4\u62FF\u56DE\u539F\u4EFB\u52A1\u800C\u4E0D\u91CD\u590D\u8BA1\u8D39\uFF1B\u7701\u7565\u65F6\u81EA\u52A8\u751F\u6210\u3002\u91CD\u8BD5\u4E0D\u786E\u5B9A\u7684\u63D0\u4EA4\u65F6\u52A1\u5FC5\u590D\u7528\u539F key").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
1767
1816
  const g = cmd.optsWithGlobals();
1768
1817
  const auth = resolveAuth(g);
1769
1818
  const model = opts.model ?? (await resolveCreativeModel(auth, "image")).model.id;
@@ -1812,12 +1861,17 @@ function registerGen(program) {
1812
1861
  if (opts.image) body.image = opts.image;
1813
1862
  if (opts.mask) body.mask = opts.mask;
1814
1863
  if (opts.responseFormat) body.response_format = opts.responseFormat;
1864
+ const idempotencyKey = opts.wait === false ? resolveIdempotencyKey(opts.idempotencyKey) : void 0;
1865
+ warnDoubleEncodedReferenceURLs("--image", opts.image ?? []);
1815
1866
  const res = await withProgress(opts.wait === false ? "\u6B63\u5728\u63D0\u4EA4\u56FE\u50CF\u4EFB\u52A1" : "\u6B63\u5728\u751F\u6210\u56FE\u50CF", () => request({
1816
1867
  baseUrl: auth.baseUrl,
1817
1868
  path: "/v1/images/generations",
1818
1869
  apiKey: auth.apiKey,
1819
1870
  body,
1820
- headers: opts.wait === false ? { Prefer: "respond-async" } : void 0,
1871
+ headers: {
1872
+ ...opts.wait === false ? { Prefer: "respond-async" } : {},
1873
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
1874
+ },
1821
1875
  timeoutMs: 6e5
1822
1876
  }));
1823
1877
  if (opts.wait === false) {
@@ -1825,8 +1879,10 @@ function registerGen(program) {
1825
1879
  if (!taskId) {
1826
1880
  throw new ApiError("bad_response", "\u5F02\u6B65\u56FE\u50CF\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: res });
1827
1881
  }
1882
+ info(`task_id=${taskId}`);
1883
+ if (idempotencyKey) info(`idempotency_key=${idempotencyKey}`);
1828
1884
  if (g.json) {
1829
- printJson({ model, task_id: taskId, status: res.status ?? "queued", submitted: true, next_command: `focalapi task status ${taskId} --json` });
1885
+ printJson({ model, task_id: taskId, status: res.status ?? "queued", submitted: true, ...res.idempotent_replay ? { idempotent_replay: true } : {}, next_command: `focalapi task status ${taskId} --json` });
1830
1886
  } else {
1831
1887
  process.stdout.write(taskId + "\n");
1832
1888
  info(`\u4EFB\u52A1\u5DF2\u63D0\u4EA4\u3002\u67E5\u8BE2\uFF1Afocalapi task status ${taskId}`);
@@ -1951,17 +2007,21 @@ function registerGen(program) {
1951
2007
  if (res.id) info(`\u4EA4\u4E92 ID\uFF1A${res.id}`);
1952
2008
  }
1953
2009
  });
1954
- gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF1B\u7CBE\u786E\u8303\u56F4\u8FD0\u884C focalapi models get <model> \u67E5\u770B", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--resolution <resolution>", "\u539F\u751F\u8F93\u51FA\u5206\u8FA8\u7387\uFF0C\u5982 480p\u3001720p\u30011080p\u30014k").option("--ratio <ratio>", "\u539F\u751F\u5BBD\u9AD8\u6BD4\uFF0C\u5982 16:9\u30019:16\u3001adaptive").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9\u30019:16\u3001auto").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--fps <n>", "\u8F93\u51FA\u5E27\u7387\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-tolerance <n>", "\u5B89\u5168\u5BB9\u5FCD\u5EA6\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--image <url...>", "\u53C2\u8003\u56FE URL\uFF08Grok \u89C6\u9891\u4E3A reference-to-video \u6A21\u5F0F\uFF0C\u53EF\u591A\u4E2A\uFF09").option("--first-frame <url>", "\u56FE\u751F\u89C6\u9891\u9996\u5E27\u56FE URL\uFF08image-to-video \u6A21\u5F0F\uFF1B\u4E0E --image \u4E92\u65A5\uFF09").option("--generate-audio <boolean>", "\u662F\u5426\u751F\u6210\u97F3\u9891\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "generate-audio")).option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--service-tier <tier>", "\u670D\u52A1\u5C42\u7EA7\uFF08Seedance 2.0 \u9ED8\u8BA4 default\uFF09").option("--priority <n>", "\u4EFB\u52A1\u4F18\u5148\u7EA7\uFF08\u4EC5 Seedance 2.0 \u7CFB\u5217\uFF09", (v) => Number.parseInt(v, 10)).option("--callback-url <url>", "\u4EFB\u52A1\u5B8C\u6210\u56DE\u8C03 URL").option("--return-last-frame <boolean>", "\u662F\u5426\u8FD4\u56DE\u6700\u540E\u4E00\u5E27\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "return-last-frame")).option("--execution-expires-after <seconds>", "\u4EFB\u52A1\u8FC7\u671F\u79D2\u6570\uFF083600\u2013259200\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-identifier <identifier>", "Seedance \u5B89\u5168\u6807\u8BC6\u7B26\uFF081\u201364 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--content <json>", "Ark-compatible content JSON array; overrides prompt/image facade fields").action(
2010
+ gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF1B--duration \u4E3A\u540C\u4E49\u522B\u540D\u3002\u7CBE\u786E\u8303\u56F4\u8FD0\u884C focalapi models get <model> \u67E5\u770B", (v) => Number.parseInt(v, 10)).option("--duration <n>", "\u65F6\u957F\u79D2\u6570\uFF08--seconds \u7684\u522B\u540D\uFF0C\u4E0E API \u5951\u7EA6\u5B57\u6BB5\u540C\u540D\uFF09", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--resolution <resolution>", "\u539F\u751F\u8F93\u51FA\u5206\u8FA8\u7387\uFF0C\u5982 480p\u3001720p\u30011080p\u30014k").option("--ratio <ratio>", "\u539F\u751F\u5BBD\u9AD8\u6BD4\uFF0C\u5982 16:9\u30019:16\u3001adaptive").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9\u30019:16\u3001auto").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--fps <n>", "\u8F93\u51FA\u5E27\u7387\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-tolerance <n>", "\u5B89\u5168\u5BB9\u5FCD\u5EA6\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--image <url...>", "\u53C2\u8003\u56FE URL\uFF08Grok \u89C6\u9891\u4E3A reference-to-video \u6A21\u5F0F\uFF0C\u53EF\u591A\u4E2A\uFF09").option("--first-frame <url>", "\u56FE\u751F\u89C6\u9891\u9996\u5E27\u56FE URL\uFF08image-to-video \u6A21\u5F0F\uFF1B\u4E0E --image \u4E92\u65A5\uFF09").option("--generate-audio <boolean>", "\u662F\u5426\u751F\u6210\u97F3\u9891\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "generate-audio")).option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--service-tier <tier>", "\u670D\u52A1\u5C42\u7EA7\uFF08Seedance 2.0 \u9ED8\u8BA4 default\uFF09").option("--priority <n>", "\u4EFB\u52A1\u4F18\u5148\u7EA7\uFF08\u4EC5 Seedance 2.0 \u7CFB\u5217\uFF09", (v) => Number.parseInt(v, 10)).option("--callback-url <url>", "\u4EFB\u52A1\u5B8C\u6210\u56DE\u8C03 URL").option("--return-last-frame <boolean>", "\u662F\u5426\u8FD4\u56DE\u6700\u540E\u4E00\u5E27\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "return-last-frame")).option("--execution-expires-after <seconds>", "\u4EFB\u52A1\u8FC7\u671F\u79D2\u6570\uFF083600\u2013259200\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-identifier <identifier>", "Seedance \u5B89\u5168\u6807\u8BC6\u7B26\uFF081\u201364 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--idempotency-key <key>", "\u5E42\u7B49\u952E\uFF088\u2013128 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09\u3002\u540C\u4E00 key \u7684\u91CD\u590D\u63D0\u4EA4\u62FF\u56DE\u539F\u4EFB\u52A1\u800C\u4E0D\u91CD\u590D\u8BA1\u8D39\uFF1B\u7701\u7565\u65F6\u81EA\u52A8\u751F\u6210\u3002\u91CD\u8BD5\u4E0D\u786E\u5B9A\u7684\u63D0\u4EA4\u65F6\u52A1\u5FC5\u590D\u7528\u539F key").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--content <json>", "Ark-compatible content JSON array; overrides prompt/image facade fields").action(
1955
2011
  async (promptParts, opts, cmd) => {
1956
2012
  const g = cmd.optsWithGlobals();
1957
2013
  const auth = resolveAuth(g);
1958
2014
  const model = opts.model ?? (await resolveCreativeModel(auth, "video")).model.id;
1959
2015
  if (!opts.model && !g.json) info(`\u5DF2\u81EA\u52A8\u9009\u62E9\u89C6\u9891\u6A21\u578B\uFF1A${model}`);
2016
+ if (opts.seconds !== void 0 && opts.duration !== void 0 && opts.seconds !== opts.duration) {
2017
+ throw new ApiError("invalid_request", `--seconds \u4E0E --duration \u662F\u540C\u4E00\u53C2\u6570\uFF0C\u53EA\u9700\u4F20\u4E00\u4E2A\uFF08\u6536\u5230 ${opts.seconds} \u548C ${opts.duration}\uFF09`);
2018
+ }
2019
+ const seconds = opts.seconds ?? opts.duration;
1960
2020
  const body = { model, prompt: promptParts.join(" ") };
1961
2021
  const metadata = {};
1962
- if (opts.seconds !== void 0) {
1963
- const seconds = clampInt(opts.seconds, 1, MAX_TASK_DURATION_SECONDS, "seconds");
1964
- body.duration = seconds;
2022
+ if (seconds !== void 0) {
2023
+ const secondsValue = clampInt(seconds, 1, MAX_TASK_DURATION_SECONDS, "seconds");
2024
+ body.duration = secondsValue;
1965
2025
  }
1966
2026
  if (opts.size) body.size = opts.size;
1967
2027
  if (opts.image) body.images = opts.image;
@@ -1982,7 +2042,7 @@ function registerGen(program) {
1982
2042
  if (opts.safetyIdentifier) metadata.safety_identifier = opts.safetyIdentifier;
1983
2043
  if (opts.content) metadata.content = parseJsonArray(opts.content, "content");
1984
2044
  validateVideoGeneration(model, {
1985
- seconds: opts.seconds,
2045
+ seconds,
1986
2046
  resolution: opts.resolution,
1987
2047
  ratio: opts.ratio,
1988
2048
  aspectRatio: opts.aspectRatio,
@@ -1999,11 +2059,14 @@ function registerGen(program) {
1999
2059
  watermark: opts.watermark
2000
2060
  });
2001
2061
  if (Object.keys(metadata).length > 0) body.metadata = metadata;
2062
+ const idempotencyKey = resolveIdempotencyKey(opts.idempotencyKey);
2063
+ warnDoubleEncodedReferenceURLs("--image/--first-frame", [...opts.image ?? [], opts.firstFrame]);
2002
2064
  const created = await withProgress("\u6B63\u5728\u63D0\u4EA4\u89C6\u9891\u4EFB\u52A1", () => request({
2003
2065
  baseUrl: auth.baseUrl,
2004
2066
  path: "/v1/video/generations",
2005
2067
  apiKey: auth.apiKey,
2006
2068
  body,
2069
+ headers: { "Idempotency-Key": idempotencyKey },
2007
2070
  timeoutMs: 12e4
2008
2071
  }));
2009
2072
  const taskId = extractTaskId(created);
@@ -2011,8 +2074,11 @@ function registerGen(program) {
2011
2074
  throw new ApiError("bad_response", "\u89C6\u9891\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: created });
2012
2075
  }
2013
2076
  if (opts.wait === false) {
2077
+ info(`task_id=${taskId}`);
2078
+ info(`idempotency_key=${idempotencyKey}`);
2079
+ if (created.idempotent_replay) info("\u5DF2\u56DE\u653E\u539F\u4EFB\u52A1\uFF08idempotent_replay\uFF0C\u672A\u91CD\u590D\u8BA1\u8D39\uFF09");
2014
2080
  if (g.json) {
2015
- printJson({ model, task_id: taskId, submitted: true, next_command: `focalapi task status ${taskId} --json` });
2081
+ printJson({ model, task_id: taskId, submitted: true, ...created.idempotent_replay ? { idempotent_replay: true } : {}, next_command: `focalapi task status ${taskId} --json` });
2016
2082
  } else {
2017
2083
  process.stdout.write(taskId + "\n");
2018
2084
  info(`\u4EFB\u52A1\u5DF2\u63D0\u4EA4\u3002\u7EED\u53D6\uFF1Afocalapi task status ${taskId} / focalapi task download ${taskId}\uFF1B\u6392\u961F\u4E2D\u53EF\u53D6\u6D88\uFF1Afocalapi task cancel ${taskId}`);
@@ -2040,29 +2106,100 @@ function registerGen(program) {
2040
2106
  }
2041
2107
 
2042
2108
  // src/commands/task.ts
2109
+ var TASK_ID_HINT = "task_id \u533A\u5206\u5927\u5C0F\u5199\u4E14\u6DF7\u6709\u5C0F\u5199 l / \u6570\u5B57 1 / \u5927\u5199 I\uFF08O \u4E0E 0 \u540C\u7406\uFF09\uFF0C\u5FC5\u987B\u9010\u5B57\u590D\u5236\u3002\u82E5\u63D0\u4EA4\u65F6\u7684\u8F93\u51FA\u5DF2\u4E22\u5931\uFF0C\u5148\u8FD0\u884C focalapi task list \u627E\u56DE\u6700\u8FD1\u7684\u4EFB\u52A1\uFF0C\u4E0D\u8981\u76F2\u76EE\u91CD\u65B0\u63D0\u4EA4\u2014\u2014\u91CD\u590D\u63D0\u4EA4\u4F1A\u91CD\u590D\u6263\u8D39\u3002";
2110
+ function withTaskIdHint(err) {
2111
+ if (err instanceof ApiError && err.status === 404) {
2112
+ return new ApiError(err.code, err.message, { status: err.status, hint: TASK_ID_HINT, body: err.body, upstreamCode: err.upstreamCode, requestId: err.requestId });
2113
+ }
2114
+ return err;
2115
+ }
2116
+ function formatElapsed(seconds) {
2117
+ if (!seconds || seconds < 0) return "-";
2118
+ if (seconds < 90) return `${seconds} \u79D2`;
2119
+ return `${Math.floor(seconds / 60)} \u5206 ${seconds % 60} \u79D2`;
2120
+ }
2121
+ function taskAgeSeconds(createdAt) {
2122
+ if (!createdAt) return void 0;
2123
+ return Math.max(0, Math.floor(Date.now() / 1e3 - createdAt));
2124
+ }
2043
2125
  function registerTask(program) {
2044
2126
  const task = program.command("task").description("\u4EFB\u52A1\u67E5\u8BE2\u3001\u53D6\u6D88\u4E0E\u4EA7\u7269\u4E0B\u8F7D\uFF08\u89C6\u9891\u7B49\u4EFB\u52A1\u5236\u80FD\u529B\uFF09");
2045
- task.command("status").description("\u67E5\u8BE2\u4EFB\u52A1\u72B6\u6001").argument("<task_id>", "\u4EFB\u52A1 ID").action(async (taskId, _opts, cmd) => {
2127
+ task.command("status").description("\u67E5\u8BE2\u4EFB\u52A1\u72B6\u6001\uFF1B--wait \u5185\u7F6E\u8F6E\u8BE2\u7B49\u5F85\u7EC8\u6001\uFF0C\u65E0\u9700\u81EA\u5199\u8F6E\u8BE2\u811A\u672C").argument("<task_id>", "\u4EFB\u52A1 ID").option("--wait", "\u7B49\u5F85\u4EFB\u52A1\u5230\u8FBE\u7EC8\u6001\uFF08\u6210\u529F / \u5931\u8D25 / \u53D6\u6D88\uFF09\u540E\u518D\u8FD4\u56DE").option("--timeout <minutes>", "--wait \u7684\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("--poll-interval <ms>", "--wait \u7684\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--download", "--wait \u6210\u529F\u540E\u81EA\u52A8\u4E0B\u8F7D\u4EA7\u7269\uFF08\u7B49\u4EF7\u4E8E\u63A5\u7740\u6267\u884C task download\uFF09").option("-o, --out <dir>", "--download \u7684\u8F93\u51FA\u76EE\u5F55", "focalapi-out").action(async (taskId, opts, cmd) => {
2128
+ const g = cmd.optsWithGlobals();
2129
+ const auth = resolveAuth(g);
2130
+ try {
2131
+ let info_ = await fetchTask(auth.baseUrl, auth.apiKey, taskId);
2132
+ if (opts.wait && info_.status !== "success" && info_.status !== "failed" && info_.status !== "cancelled") {
2133
+ info_ = await pollTask(auth.baseUrl, auth.apiKey, taskId, {
2134
+ intervalMs: opts.pollInterval,
2135
+ timeoutMs: opts.timeout * 6e4,
2136
+ onUpdate: (t) => {
2137
+ if (!g.json) {
2138
+ const elapsed2 = formatElapsed(taskAgeSeconds(t.createdAt));
2139
+ info(` \u72B6\u6001\uFF1A${t.rawStatus || t.status}${t.progress !== void 0 ? `\uFF08${t.progress}%\uFF09` : ""}\uFF0C\u5DF2\u8017\u65F6 ${elapsed2}`);
2140
+ }
2141
+ }
2142
+ });
2143
+ }
2144
+ const elapsed = taskAgeSeconds(info_.createdAt);
2145
+ let file;
2146
+ if (opts.download && info_.status === "success") {
2147
+ file = await downloadTaskContent(auth.baseUrl, auth.apiKey, taskId, opts.out);
2148
+ }
2149
+ if (g.json) {
2150
+ printJson({
2151
+ task_id: taskId,
2152
+ status: info_.status,
2153
+ raw_status: info_.rawStatus,
2154
+ progress: info_.progress,
2155
+ ...elapsed !== void 0 ? { elapsed_seconds: elapsed } : {},
2156
+ ...file ? { file } : {},
2157
+ raw: info_.raw
2158
+ });
2159
+ } else {
2160
+ printTable(
2161
+ ["\u5B57\u6BB5", "\u503C"],
2162
+ [
2163
+ ["\u4EFB\u52A1 ID", taskId],
2164
+ ["\u72B6\u6001", `${info_.status}${info_.rawStatus && info_.rawStatus !== info_.status ? `\uFF08\u4E0A\u6E38\uFF1A${info_.rawStatus}\uFF09` : ""}`],
2165
+ ["\u8FDB\u5EA6", info_.progress !== void 0 ? `${info_.progress}%` : "-"],
2166
+ ["\u5DF2\u8017\u65F6", formatElapsed(elapsed)]
2167
+ ]
2168
+ );
2169
+ if (file) {
2170
+ info(`\u2713 ${file}`);
2171
+ } else if (info_.status === "success") {
2172
+ info(`\u4EA7\u7269\u4E0B\u8F7D\uFF1Afocalapi task download ${taskId}`);
2173
+ }
2174
+ if (info_.status === "pending" || info_.status === "running") {
2175
+ info(`\u7B49\u5F85\u5B8C\u6210\uFF1Afocalapi task status ${taskId} --wait\uFF1B\u6392\u961F\u4E2D\u53EF\u53D6\u6D88\uFF1Afocalapi task cancel ${taskId}`);
2176
+ }
2177
+ }
2178
+ } catch (err) {
2179
+ throw withTaskIdHint(err);
2180
+ }
2181
+ });
2182
+ task.command("list").description("\u5217\u51FA\u5F53\u524D Key \u7684\u8FD1\u671F\u4EFB\u52A1\uFF08\u63D0\u4EA4\u8F93\u51FA\u4E22\u5931\u65F6\u7528\u5B83\u627E\u56DE task_id\uFF09").option("--status <status>", "\u6309\u72B6\u6001\u8FC7\u6EE4\uFF1Aqueued\u3001in_progress\u3001completed\u3001failed\u3001cancelled").option("--action <action>", "\u6309\u4EFB\u52A1\u7C7B\u578B\u8FC7\u6EE4\uFF0C\u5982 generate\u3001image_generation").option("--limit <n>", "\u6BCF\u9875\u6570\u91CF\uFF081\u2013100\uFF09", (v) => Number.parseInt(v, 10), 20).option("--offset <n>", "\u504F\u79FB\u91CF", (v) => Number.parseInt(v, 10), 0).action(async (opts, cmd) => {
2046
2183
  const g = cmd.optsWithGlobals();
2047
2184
  const auth = resolveAuth(g);
2048
- const info_ = await fetchTask(auth.baseUrl, auth.apiKey, taskId);
2185
+ const items = await listTasks(auth.baseUrl, auth.apiKey, opts);
2049
2186
  if (g.json) {
2050
- printJson({ task_id: taskId, status: info_.status, raw_status: info_.rawStatus, progress: info_.progress, raw: info_.raw });
2187
+ printJson({ object: "list", data: items });
2188
+ } else if (items.length === 0) {
2189
+ info("\u5F53\u524D Key \u6682\u65E0\u4EFB\u52A1\u8BB0\u5F55\u3002");
2051
2190
  } else {
2052
2191
  printTable(
2053
- ["\u5B57\u6BB5", "\u503C"],
2054
- [
2055
- ["\u4EFB\u52A1 ID", taskId],
2056
- ["\u72B6\u6001", `${info_.status}${info_.rawStatus && info_.rawStatus !== info_.status ? `\uFF08\u4E0A\u6E38\uFF1A${info_.rawStatus}\uFF09` : ""}`],
2057
- ["\u8FDB\u5EA6", info_.progress !== void 0 ? `${info_.progress}%` : "-"]
2058
- ]
2192
+ ["\u4EFB\u52A1 ID", "\u6A21\u578B", "\u72B6\u6001", "\u8FDB\u5EA6", "\u5DF2\u8017\u65F6", "\u989D\u5EA6"],
2193
+ items.map((item) => [
2194
+ item.task_id,
2195
+ item.model ?? "-",
2196
+ item.status ?? "-",
2197
+ item.progress !== void 0 ? `${item.progress}%` : "-",
2198
+ formatElapsed(item.created_at ? Math.max(0, Math.floor(Date.now() / 1e3 - item.created_at)) : void 0),
2199
+ item.quota !== void 0 ? String(item.quota) : "-"
2200
+ ])
2059
2201
  );
2060
- if (info_.status === "success") {
2061
- info(`\u4EA7\u7269\u4E0B\u8F7D\uFF1Afocalapi task download ${taskId}`);
2062
- }
2063
- if (info_.status === "pending" || info_.status === "running") {
2064
- info(`\u5982\u9700\u505C\u6B62\u6392\u961F\u4E2D\u7684\u4EFB\u52A1\uFF1Afocalapi task cancel ${taskId}`);
2065
- }
2202
+ info("\u7EED\u53D6\uFF1Afocalapi task status <task_id> --wait");
2066
2203
  }
2067
2204
  });
2068
2205
  task.command("cancel").description("\u53D6\u6D88\u6392\u961F\u4E2D\u7684\u4EFB\u52A1\uFF08\u8FD0\u884C\u4E2D\u7684\u4EFB\u52A1\u4E0D\u53EF\u53D6\u6D88\uFF1B\u53D6\u6D88\u540E\u8D39\u7528\u81EA\u52A8\u9000\u8FD8\uFF09").argument("<task_id>", "\u4EFB\u52A1 ID").action(async (taskId, _opts, cmd) => {
@@ -2080,7 +2217,7 @@ function registerTask(program) {
2080
2217
  const auth = resolveAuth(g);
2081
2218
  const filePath = await downloadTaskContent(auth.baseUrl, auth.apiKey, taskId, opts.out);
2082
2219
  if (g.json) {
2083
- printJson({ task_id: taskId, file: filePath });
2220
+ printJson({ task_id: taskId, file: filePath, files: [filePath] });
2084
2221
  } else {
2085
2222
  info(`\u2713 ${filePath}`);
2086
2223
  }
@@ -2187,7 +2324,7 @@ function registerUsage(program) {
2187
2324
  }
2188
2325
 
2189
2326
  // src/commands/connect.ts
2190
- import { createHash, randomUUID } from "crypto";
2327
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
2191
2328
  import {
2192
2329
  cpSync,
2193
2330
  existsSync as existsSync2,
@@ -2346,7 +2483,7 @@ function installTo(skillsDir, agents, skills, srcDir) {
2346
2483
  }
2347
2484
  mkdirSync2(skillsDir, { recursive: true });
2348
2485
  const oldManifest = readManifest(skillsDir);
2349
- const transactionRoot = join4(skillsDir, `.focalapi-install-${randomUUID()}`);
2486
+ const transactionRoot = join4(skillsDir, `.focalapi-install-${randomUUID2()}`);
2350
2487
  const stageRoot = join4(transactionRoot, "stage");
2351
2488
  const backupRoot = join4(transactionRoot, "backup");
2352
2489
  mkdirSync2(stageRoot, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focalapi-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "让任意 AI Agent 直接调用 focalapi 创作模型的命令行工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@ FocalAPI is a creative-model gateway for the current Agent to call. It is not th
17
17
  1. If the user does not specify a model, omit `--model`. The CLI selects a FocalAPI default from the live model pool and detailed contracts available to the current key. Do not generate a test sample first.
18
18
  2. If the user specifies a model or provider, run `focalapi models get <model-id> --json` first. If the model ID is incomplete, run `models search` once to find the exact ID, then read its details.
19
19
  3. Use only parameters and values listed in the detailed `supported_params`. Never infer support from a similar model.
20
- 4. Add `--json` for Agent and script calls. stdout is the only machine-readable result; diagnostics go to stderr.
20
+ 4. Add `--json` for Agent and script calls. stdout is the only machine-readable result; diagnostics go to stderr. Do not merge the two streams (`2>&1`) before parsing. Programmatic callers on Windows must invoke the CLI through a shell (`cmd /c focalapi ...` or `shell=True`) because npm installs a `.cmd` shim.
21
21
  5. After successful generation, return local absolute file paths to the user. If a video response contains `task_id`, follow `next_command`; never submit the same task again.
22
22
 
23
23
  ```bash
@@ -26,8 +26,8 @@ focalapi gen image "<user prompt>" -o ./focalapi-out --json
26
26
  focalapi gen video "<user prompt>" --no-wait -o ./focalapi-out --json
27
27
 
28
28
  # Continue an asynchronous video task.
29
- focalapi task status <task-id> --json
30
- focalapi task download <task-id> -o ./focalapi-out --json
29
+ focalapi task status <task-id> --wait --json # built-in polling; never write your own poll script
30
+ focalapi task list --json # recover recent task IDs after a lost output
31
31
  focalapi task cancel <task-id> --json # queued tasks only; cancelled tasks are refunded
32
32
  ```
33
33
 
@@ -38,7 +38,7 @@ focalapi task cancel <task-id> --json # queued tasks only; cancelled tasks are
38
38
  | Generate or edit images; create from reference images | `focalapi gen image` | focalapi-gen |
39
39
  | Generate video; animate images or reference media | `focalapi gen video` | focalapi-gen |
40
40
  | Select, compare, or inspect model parameters | `focalapi models resolve/get/search` | focalapi-models |
41
- | Inspect progress or failures; cancel queued tasks; download results | `focalapi task status/cancel/download` | focalapi-task |
41
+ | Inspect progress or failures; wait, list, cancel queued tasks; download results | `focalapi task status/wait/list/cancel/download` | focalapi-task |
42
42
  | Resolve key, sign-in, or 401 issues | `focalapi auth status/login` | focalapi-auth |
43
43
  | Inspect quota, usage, or service failures | `focalapi usage/doctor` | focalapi-usage |
44
44
  | Provide text assistance explicitly requested by the user | `focalapi chat` | focalapi-chat |
@@ -50,6 +50,8 @@ focalapi task download <task-id> -o ./focalapi-out --json
50
50
 
51
51
  `pending` and `running` are not failures. Keep checking the same `task_id` and never resubmit generation. On failure, read the structured `error.code` and `hint`, and fix only the explicit problem instead of rotating models blindly.
52
52
 
53
+ Never resubmit after an output-parsing failure on your side. If your own pipeline fails to parse a submission command's stdout JSON, the task was almost certainly created and charged: recover the `task_id` from the command's stderr breadcrumb line (`task_id=...`) and continue with `task status`. Resubmitting a request whose outcome is unknown duplicates the charge.
54
+
53
55
  Two transient outcomes need no parameter changes:
54
56
 
55
57
  - `capacity_exhausted` (HTTP 503): the platform queue is full. Retry the same command after roughly 10 seconds; do not switch models or shrink the request.
@@ -13,8 +13,10 @@ metadata:
13
13
  After a generation command returns `task_id`, prefer the response's `next_command`:
14
14
 
15
15
  ```bash
16
- focalapi task status <task-id> --json
17
- focalapi task download <task-id> -o ./focalapi-out --json
16
+ focalapi task status <task-id> --json # one-shot status check
17
+ focalapi task status <task-id> --wait # built-in polling until a terminal state — do not write your own poller
18
+ focalapi task status <task-id> --wait --download -o ./focalapi-out --json
19
+ focalapi task list --status in_progress --json # reconcile recent tasks after losing a submission output
18
20
  focalapi task cancel <task-id> --json
19
21
  ```
20
22
 
@@ -24,6 +26,10 @@ focalapi task cancel <task-id> --json
24
26
  - `cancelled`: the task was stopped; a cancelled queued task is refunded. Do not download or resubmit unless the user asks for a new attempt.
25
27
  - `unknown`: preserve the raw response and run `focalapi doctor --json`; never fabricate a success state.
26
28
 
29
+ Task IDs are case-sensitive and mix `l`/`1`/`I` and `O`/`0`. Copy them exactly from the submission output — stdout JSON or the stderr `task_id=` breadcrumb — instead of retyping. If a submission output is lost entirely, run `task list` to recover recent task IDs before considering any resubmission.
30
+
31
+ Generation commands print the submission's `idempotency_key=` on stderr. When retrying a submission whose outcome is uncertain (timeout, crash, lost output), reuse that same key via `--idempotency-key` so the retry replays the original task instead of double-charging; a fresh key means a deliberate new generation.
32
+
27
33
  `task cancel` only works while a task is still queued (`pending`). A 409 `task_already_running` means generation already started and cannot be stopped — keep tracking with `task status`. Cancellation failures return explicit codes (`task_already_finished`, `task_cancel_failed`); follow `error.hint` instead of retrying blindly.
28
34
 
29
35
  Polling must be bounded. When the user does not ask for blocking wait behavior, report the current status and `task_id`.