image-skill 0.1.35 → 0.1.37

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
@@ -4,6 +4,34 @@ This changelog tracks the public `image-skill` CLI package and public skill
4
4
  mirror. The npm package metadata remains the authority for tarball integrity and
5
5
  provenance; this file is the human- and agent-readable release map.
6
6
 
7
+ ## 0.1.37 - 2026-06-09
8
+
9
+ - Fix (recovery): a live `create`/`edit` now leaves a recovery handle _before_
10
+ the blocking request. Every live (non-dry-run) call carries an idempotency
11
+ key even when you did not pass `--idempotency-key`, emits an `in_flight`
12
+ notice with that key to stderr, and writes a durable breadcrumb at
13
+ `<config-dir>/in-flight/<key>.json`. If the command is interrupted (for
14
+ example you kill a create that hangs on a long provider wait after the credit
15
+ was already reserved), re-run it with the surfaced key: the hosted API
16
+ replays the original job (returning the asset you already paid for) or
17
+ releases the reserved credit — never a double charge. The stdout JSON
18
+ envelope is unchanged. Fixes the "create debited credits but no live job or
19
+ asset surfaced" report (#1789).
20
+ - Fix (recovery): the proxy-killed non-JSON 5xx retry recovery now echoes the
21
+ same idempotency key the charged request used, so the advertised retry
22
+ genuinely dedupes instead of minting a non-matching key (#1228 follow-up).
23
+
24
+ ## 0.1.36 - 2026-06-04
25
+
26
+ - Fix (guide): `create --guide --json` now marks templated follow-up commands
27
+ explicitly with `data.next_command_copy_runnable`,
28
+ `data.next_command_missing_inputs`, and
29
+ `data.next_command_effect.requires_placeholder_substitution`. Auth signup,
30
+ prompt recovery, payment handoff, and input-asset templates remain visible to
31
+ agents, but placeholder values such as `AGENT_OR_OPERATOR_INBOX`,
32
+ `AGENT_NAME`, `RUNTIME_NAME`, `QUOTE_ID`, and `PAYMENT_ATTEMPT_ID` are no
33
+ longer presented as if the command can be copied blindly.
34
+
7
35
  ## 0.1.35 - 2026-06-04
8
36
 
9
37
  - Fix (CLI aliases): natural modality-first commands now route into the
@@ -7,7 +7,7 @@ import { Readable } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
8
  import os from "node:os";
9
9
 
10
- const VERSION = "0.1.35";
10
+ const VERSION = "0.1.37";
11
11
  const PACKAGE_NAME = "image-skill";
12
12
  const DEFAULT_API_BASE_URL = "https://api.image-skill.com";
13
13
  const DEFAULT_DOCS_BASE_URL = "https://image-skill.com";
@@ -65,6 +65,70 @@ const PAYMENT_CREDENTIAL_FLAGS = new Set([
65
65
  "provider-key",
66
66
  "provider-receipt",
67
67
  ]);
68
+ const GUIDE_NEXT_COMMAND_PLACEHOLDERS = [
69
+ {
70
+ placeholder: "AGENT_OR_OPERATOR_INBOX",
71
+ flag: "--agent-contact",
72
+ value_description:
73
+ "Email-shaped durable contact inbox for the restricted agent identity; use an agent-owned inbox when available, otherwise an operator, team, or sponsor inbox.",
74
+ effect_description: "email-shaped durable contact inbox",
75
+ example: "agent-inbox@example.com",
76
+ },
77
+ {
78
+ placeholder: "AGENT_NAME",
79
+ flag: "--agent-name",
80
+ value_description:
81
+ "Stable display name for this restricted agent identity.",
82
+ effect_description: "stable agent identity name",
83
+ example: "codex-image-worker",
84
+ },
85
+ {
86
+ placeholder: "RUNTIME_NAME",
87
+ flag: "--runtime",
88
+ value_description:
89
+ "Stable name for the agent runtime or substrate using Image Skill.",
90
+ effect_description: "agent/runtime substrate name",
91
+ example: "codex-cli",
92
+ },
93
+ {
94
+ placeholder: "PROMPT",
95
+ flag: "--prompt",
96
+ value_description: "The real creative prompt to plan or create.",
97
+ effect_description: "real creative prompt",
98
+ example: "a compact field camera on a stainless workbench",
99
+ },
100
+ {
101
+ placeholder: "KEY",
102
+ flag: "--idempotency-key",
103
+ value_description:
104
+ "Unique idempotency key for this payment or create attempt.",
105
+ effect_description: "unique idempotency key",
106
+ example: "agent-generated-idempotency-key",
107
+ },
108
+ {
109
+ placeholder: "QUOTE_ID",
110
+ flag: "--quote-id",
111
+ value_description: "Quote id returned by the preceding credits quote call.",
112
+ effect_description: "quote id from credits quote",
113
+ example: null,
114
+ },
115
+ {
116
+ placeholder: "PAYMENT_ATTEMPT_ID",
117
+ flag: "--payment-attempt-id",
118
+ value_description:
119
+ "Payment attempt id returned by the preceding credits buy call.",
120
+ effect_description: "payment attempt id from credits buy",
121
+ example: null,
122
+ },
123
+ {
124
+ placeholder: "image_...",
125
+ flag: "--input",
126
+ value_description:
127
+ "Image Skill input asset id, usually from upload, assets, jobs, or a previous create.",
128
+ effect_description: "Image Skill input asset id",
129
+ example: null,
130
+ },
131
+ ];
68
132
 
69
133
  const argv = normalizePublicArgv(process.argv.slice(2));
70
134
  const result = await main(argv);
@@ -212,24 +276,26 @@ function helpKey(path) {
212
276
  function commandHelpByKey(key) {
213
277
  return {
214
278
  "": {
215
- command: "image-skill help",
279
+ command: "help",
216
280
  usage:
217
- "image-skill <doctor|trust|signup|auth|whoami|usage|quota|credits|models|capabilities|create|upload|edit|assets|jobs|activity|feedback> --json",
281
+ "image-skill <doctor|trust|signup|whoami|usage|quota|credits|capabilities|models|create|upload|edit|assets|jobs|activity|feedback> --json",
218
282
  docs_url: "https://image-skill.com/cli.md",
219
283
  commands: [
220
284
  "doctor",
221
285
  "trust",
222
286
  "signup --agent --agent-contact --agent-name NAME --runtime RUNTIME",
223
- "auth status",
224
- "auth save",
225
- "auth logout",
226
287
  "whoami",
227
288
  "usage quota",
289
+ "quota",
228
290
  "credits methods",
229
- "credits packs list",
230
291
  "credits quote",
292
+ "credits packs list",
231
293
  "credits buy",
232
294
  "credits status",
295
+ "capabilities",
296
+ "capabilities list",
297
+ "capabilities show",
298
+ "models",
233
299
  "models list",
234
300
  "models show",
235
301
  "create --guide",
@@ -237,8 +303,7 @@ function commandHelpByKey(key) {
237
303
  "video create --guide",
238
304
  "audio create --guide",
239
305
  "3d create --guide",
240
- "capabilities list",
241
- "capabilities show",
306
+ "create --dry-run",
242
307
  "create",
243
308
  "image edit",
244
309
  "upload",
@@ -329,7 +394,7 @@ function commandHelpByKey(key) {
329
394
  },
330
395
  "credits methods": {
331
396
  command: "image-skill credits methods help",
332
- usage: "image-skill credits methods --json",
397
+ usage: "image-skill credits methods",
333
398
  docs_url: "https://image-skill.com/cli.md#image-skill-credits",
334
399
  },
335
400
  "credits packs": {
@@ -372,6 +437,16 @@ function commandHelpByKey(key) {
372
437
  usage:
373
438
  "image-skill models list --available --operation image.generate --json",
374
439
  docs_url: "https://image-skill.com/cli.md#image-skill-models",
440
+ optional_flags: [
441
+ "--available",
442
+ "--executable",
443
+ "--catalog-only",
444
+ "--operation",
445
+ "--modality",
446
+ "--provider",
447
+ "--summary",
448
+ "--details",
449
+ ],
375
450
  },
376
451
  "models show": {
377
452
  command: "image-skill models show help",
@@ -405,6 +480,9 @@ function commandHelpByKey(key) {
405
480
  "--model",
406
481
  "--aspect-ratio",
407
482
  "--output-count",
483
+ "--element-frontal",
484
+ "--element-reference",
485
+ "--reference-image",
408
486
  "--model-parameters-json",
409
487
  "--idempotency-key",
410
488
  ],
@@ -425,6 +503,8 @@ function commandHelpByKey(key) {
425
503
  "--model",
426
504
  "--mask",
427
505
  "--element-reference",
506
+ "--element-frontal",
507
+ "--reference-image",
428
508
  "--model-parameters-json",
429
509
  "--idempotency-key",
430
510
  ],
@@ -937,12 +1017,6 @@ async function credits(argv) {
937
1017
  (flag) =>
938
1018
  !["json", "api-base-url", "token", "token-stdin"].includes(flag),
939
1019
  );
940
- if (!flagBool(args, "json")) {
941
- return invalid(
942
- "image-skill credits methods",
943
- "credits methods requires --json",
944
- );
945
- }
946
1020
  if (args.positionals.length > 0 || unknownFlags.length > 0) {
947
1021
  return invalid(
948
1022
  "image-skill credits methods",
@@ -1033,7 +1107,7 @@ async function credits(argv) {
1033
1107
  if (paymentMethod === null) {
1034
1108
  return invalid(
1035
1109
  "image-skill credits quote",
1036
- "credits quote requires --payment-method from credits methods --json; use stripe_x402.exact.usdc for an agent-settleable browserless rail or stripe_checkout for a human Checkout handoff",
1110
+ "credits quote requires --payment-method from credits methods; use stripe_x402.exact.usdc for an agent-settleable browserless rail or stripe_checkout for a human Checkout handoff",
1037
1111
  );
1038
1112
  }
1039
1113
  if (!PUBLIC_QUOTE_PAYMENT_METHODS.includes(paymentMethod)) {
@@ -1174,13 +1248,13 @@ async function models(argv) {
1174
1248
  subcommand === "list" || subcommand === "show" ? rest : argv,
1175
1249
  );
1176
1250
  if (subcommand === "show") {
1177
- const modelId = args.positionals[0];
1178
- if (modelId === undefined) {
1251
+ if (args.positionals.length !== 1) {
1179
1252
  return invalid(
1180
1253
  "image-skill models show",
1181
- "models show requires MODEL_ID",
1254
+ "models show requires exactly one MODEL_ID",
1182
1255
  );
1183
1256
  }
1257
+ const modelId = args.positionals[0];
1184
1258
  return apiRequest({
1185
1259
  command: "image-skill models show",
1186
1260
  method: "GET",
@@ -1209,13 +1283,21 @@ async function models(argv) {
1209
1283
  apiBaseUrl: apiBase(args),
1210
1284
  path: query.path,
1211
1285
  });
1212
- return flagBool(args, "summary") ? withModelSummary(result) : result;
1286
+ return flagBool(args, "details") ? result : withModelSummary(result);
1213
1287
  }
1214
1288
 
1215
1289
  function modelListQuery(args) {
1216
1290
  const available = flagBool(args, "available");
1217
1291
  const executable = flagBool(args, "executable");
1218
1292
  const catalogOnly = flagBool(args, "catalog-only");
1293
+ const summary = flagBool(args, "summary");
1294
+ const details = flagBool(args, "details");
1295
+ if (summary && details) {
1296
+ return {
1297
+ ok: false,
1298
+ message: "models list --summary cannot be combined with --details",
1299
+ };
1300
+ }
1219
1301
  if (catalogOnly && (available || executable)) {
1220
1302
  return {
1221
1303
  ok: false,
@@ -1233,6 +1315,9 @@ function modelListQuery(args) {
1233
1315
  if (catalogOnly) {
1234
1316
  params.set("catalog_only", "true");
1235
1317
  }
1318
+ if (details) {
1319
+ params.set("details", "true");
1320
+ }
1236
1321
  addQueryValue(params, "operation", flagString(args, "operation"));
1237
1322
  addQueryValue(params, "modality", flagString(args, "modality"));
1238
1323
  addQueryValue(params, "provider", flagString(args, "provider"));
@@ -1248,6 +1333,9 @@ function withModelSummary(result) {
1248
1333
  if (!isRecord(data) || !Array.isArray(data.models)) {
1249
1334
  return result;
1250
1335
  }
1336
+ if (data.summary?.result_shape === "compact_model_summary") {
1337
+ return result;
1338
+ }
1251
1339
  return {
1252
1340
  ...result,
1253
1341
  envelope: {
@@ -1257,7 +1345,7 @@ function withModelSummary(result) {
1257
1345
  summary: {
1258
1346
  ...(isRecord(data.summary) ? data.summary : {}),
1259
1347
  result_shape: "compact_model_summary",
1260
- full_list_command: "image-skill models list --json",
1348
+ full_list_command: "image-skill models list --details --json",
1261
1349
  },
1262
1350
  models: data.models.map(modelSummaryRow),
1263
1351
  },
@@ -1268,11 +1356,13 @@ function withModelSummary(result) {
1268
1356
  function modelSummaryRow(model) {
1269
1357
  return {
1270
1358
  id: model.id,
1359
+ default: model.default === true,
1271
1360
  display_name: model.display_name,
1272
1361
  provider_id: model.provider_id,
1273
1362
  mode: model.mode,
1274
1363
  status: model.status,
1275
1364
  availability_reason: model.availability_reason ?? null,
1365
+ modality: model.modality ?? "image",
1276
1366
  supports: Array.isArray(model.supports) ? [...model.supports] : [],
1277
1367
  operations: Array.isArray(model.operations) ? [...model.operations] : [],
1278
1368
  task_tags: modelSummaryTaskTags(model),
@@ -1545,6 +1635,9 @@ async function createGuide(args) {
1545
1635
  commandPrefix: guideCommandPrefix,
1546
1636
  authConfigWritable: authConfigWrite?.ok ?? true,
1547
1637
  });
1638
+ const nextCommandMissingInputs =
1639
+ createGuideNextCommandMissingInputs(nextCommand);
1640
+ const nextCommandCopyRunnable = nextCommandMissingInputs.length === 0;
1548
1641
  const escapeHatches = createGuideEscapeHatches({
1549
1642
  prompt: trimmedPrompt,
1550
1643
  selected,
@@ -1561,6 +1654,8 @@ async function createGuide(args) {
1561
1654
  const nextCommandEffect = createGuideNextCommandEffect(stage, {
1562
1655
  estimatedCredits,
1563
1656
  estimatedDebitUsdPerImage,
1657
+ nextCommandCopyRunnable,
1658
+ nextCommandMissingInputs,
1564
1659
  });
1565
1660
  const noSpendNextCommand =
1566
1661
  stage === "ready_to_create" ? escapeHatches.dry_run : null;
@@ -1580,6 +1675,7 @@ async function createGuide(args) {
1580
1675
  const guideWarning = createGuideWarning(stage, {
1581
1676
  nextCommandEffect,
1582
1677
  paymentSummary,
1678
+ nextCommandCopyRunnable,
1583
1679
  });
1584
1680
  const selfFundNextCommand = stage === "quota_required" ? nextCommand : null;
1585
1681
  const selfFundNextCommandLabel = createGuideSelfFundNextCommandLabel(
@@ -1610,6 +1706,7 @@ async function createGuide(args) {
1610
1706
  authenticated,
1611
1707
  tokenSource: publicTokenSource,
1612
1708
  savedConfigPath: configPath(),
1709
+ nextCommandCopyRunnable,
1613
1710
  });
1614
1711
  const selfFundHandoff = createGuideSelfFundHandoff(stage, {
1615
1712
  paymentSummary,
@@ -1703,6 +1800,8 @@ async function createGuide(args) {
1703
1800
  guide_warning: guideWarning,
1704
1801
  auth_ready: authReady,
1705
1802
  next_command: nextCommand,
1803
+ next_command_copy_runnable: nextCommandCopyRunnable,
1804
+ next_command_missing_inputs: nextCommandMissingInputs,
1706
1805
  next_command_effect: nextCommandEffect,
1707
1806
  no_spend_next_command: noSpendNextCommand,
1708
1807
  no_spend_next_command_label: noSpendNextCommandLabel,
@@ -2302,7 +2401,9 @@ function createGuideAuthReady(stage, input) {
2302
2401
  warning: ready
2303
2402
  ? "Current hosted auth is ready; data.next_command can reuse this auth context without exposing a raw token."
2304
2403
  : stage === "auth_required"
2305
- ? "Auth is not ready yet; run data.next_command to create a restricted agent identity, then rerun the guide."
2404
+ ? input.nextCommandCopyRunnable
2405
+ ? "Auth is not ready yet; run data.next_command to create a restricted agent identity, then rerun the guide."
2406
+ : "Auth is not ready yet; fill data.next_command_missing_inputs before running the data.next_command signup template, then rerun the guide."
2306
2407
  : null,
2307
2408
  };
2308
2409
  }
@@ -2433,6 +2534,9 @@ function createGuideWalletSettlementHandoff({
2433
2534
  }
2434
2535
 
2435
2536
  function createGuideNextCommandEffect(stage, input) {
2537
+ const placeholders = createGuideEffectPlaceholders(
2538
+ input.nextCommandMissingInputs,
2539
+ );
2436
2540
  const base = {
2437
2541
  label: "read_only_or_no_media_setup",
2438
2542
  no_spend: true,
@@ -2444,6 +2548,9 @@ function createGuideNextCommandEffect(stage, input) {
2444
2548
  media_write: false,
2445
2549
  estimated_credits: null,
2446
2550
  estimated_debit_usd_per_image: null,
2551
+ copy_runnable: input.nextCommandCopyRunnable,
2552
+ requires_placeholder_substitution: placeholders.length > 0,
2553
+ placeholders,
2447
2554
  warning: null,
2448
2555
  };
2449
2556
  if (stage === "auth_required") {
@@ -2477,6 +2584,9 @@ function createGuideNextCommandEffect(stage, input) {
2477
2584
  media_write: true,
2478
2585
  estimated_credits: input.estimatedCredits,
2479
2586
  estimated_debit_usd_per_image: input.estimatedDebitUsdPerImage,
2587
+ copy_runnable: input.nextCommandCopyRunnable,
2588
+ requires_placeholder_substitution: placeholders.length > 0,
2589
+ placeholders,
2480
2590
  warning:
2481
2591
  "data.next_command creates hosted media and can debit credits. For no-spend verification, run data.recommended_no_spend_command (same value as data.no_spend_next_command) instead.",
2482
2592
  };
@@ -2572,8 +2682,9 @@ function createGuideWarning(stage, input) {
2572
2682
  ...base,
2573
2683
  next_command_safety: "rerun_guide_no_spend",
2574
2684
  recommended_command_field: "next_command",
2575
- warning:
2576
- "data.next_command reruns the free guide with a real prompt; it does not call a provider, open payment, debit credits, or create media.",
2685
+ warning: input.nextCommandCopyRunnable
2686
+ ? "data.next_command reruns the free guide with a real prompt; it does not call a provider, open payment, debit credits, or create media."
2687
+ : "data.next_command is a no-spend guide template; fill data.next_command_missing_inputs before running it. It does not call a provider, open payment, debit credits, or create media.",
2577
2688
  };
2578
2689
  }
2579
2690
  if (stage === "no_executable_model" || stage === "service_unreachable") {
@@ -2590,8 +2701,9 @@ function createGuideWarning(stage, input) {
2590
2701
  ...base,
2591
2702
  next_command_safety: "hosted_signup_no_spend_setup",
2592
2703
  recommended_command_field: "next_command",
2593
- warning:
2594
- "data.next_command is no-spend hosted signup/setup; it creates a restricted agent identity but does not call a provider, open payment, debit credits, or create media.",
2704
+ warning: input.nextCommandCopyRunnable
2705
+ ? "data.next_command is no-spend hosted signup/setup; it creates a restricted agent identity but does not call a provider, open payment, debit credits, or create media."
2706
+ : "data.next_command is a no-spend hosted signup/setup template; fill data.next_command_missing_inputs before running it. It creates a restricted agent identity but does not call a provider, open payment, debit credits, or create media.",
2595
2707
  };
2596
2708
  }
2597
2709
  if (stage === "quota_required") {
@@ -2605,12 +2717,17 @@ function createGuideWarning(stage, input) {
2605
2717
  spend_required: true,
2606
2718
  recommended_command_field: "escape_hatches",
2607
2719
  payment_top_up_path: paymentTopUpPath,
2608
- warning:
2609
- paymentTopUpPath === "browserless_agent_self_fund"
2720
+ warning: input.nextCommandCopyRunnable
2721
+ ? paymentTopUpPath === "browserless_agent_self_fund"
2610
2722
  ? "data.next_command starts the browserless live-money top-up path; stay within the delegated cap, or use data.escape_hatches.payment_methods for read-only payment inspection."
2611
2723
  : paymentTopUpPath === "human_payment_handoff"
2612
2724
  ? "data.next_command starts a live-money payment handoff that needs human or browser completion; stay within the delegated cap, or use data.escape_hatches.payment_methods for read-only inspection."
2613
- : "data.next_command starts payment or quota recovery; inspect data.checks.payments before attempting live money, or use data.escape_hatches.payment_methods for read-only inspection.",
2725
+ : "data.next_command starts payment or quota recovery; inspect data.checks.payments before attempting live money, or use data.escape_hatches.payment_methods for read-only inspection."
2726
+ : paymentTopUpPath === "browserless_agent_self_fund"
2727
+ ? "data.next_command is a browserless live-money top-up template; fill data.next_command_missing_inputs before running it, stay within the delegated cap, or use data.escape_hatches.payment_methods for read-only payment inspection."
2728
+ : paymentTopUpPath === "human_payment_handoff"
2729
+ ? "data.next_command is a live-money payment handoff template; fill data.next_command_missing_inputs before running it, stay within the delegated cap, or use data.escape_hatches.payment_methods for read-only inspection."
2730
+ : "data.next_command is a live-money payment template; fill data.next_command_missing_inputs before running it, stay within the delegated cap, or use data.escape_hatches.payment_methods for read-only inspection.",
2614
2731
  };
2615
2732
  }
2616
2733
  return {
@@ -2624,6 +2741,40 @@ function createGuideWarning(stage, input) {
2624
2741
  };
2625
2742
  }
2626
2743
 
2744
+ function createGuideNextCommandMissingInputs(command) {
2745
+ return GUIDE_NEXT_COMMAND_PLACEHOLDERS.filter((placeholder) =>
2746
+ commandContainsTemplateToken(command, placeholder.placeholder),
2747
+ ).map((placeholder) => ({
2748
+ flag: placeholder.flag,
2749
+ placeholder: placeholder.placeholder,
2750
+ value_description: placeholder.value_description,
2751
+ example: placeholder.example,
2752
+ }));
2753
+ }
2754
+
2755
+ function createGuideEffectPlaceholders(missingInputs) {
2756
+ return missingInputs.map((input) => {
2757
+ const placeholder = GUIDE_NEXT_COMMAND_PLACEHOLDERS.find(
2758
+ (candidate) => candidate.placeholder === input.placeholder,
2759
+ );
2760
+ return {
2761
+ token: input.placeholder,
2762
+ description: placeholder?.effect_description ?? input.value_description,
2763
+ required: true,
2764
+ };
2765
+ });
2766
+ }
2767
+
2768
+ function commandContainsTemplateToken(command, token) {
2769
+ return new RegExp(
2770
+ `(^|[^A-Za-z0-9_])${escapeRegExp(token)}(?=$|[^A-Za-z0-9_])`,
2771
+ ).test(command);
2772
+ }
2773
+
2774
+ function escapeRegExp(value) {
2775
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2776
+ }
2777
+
2627
2778
  function createGuideNextCommand(stage, input) {
2628
2779
  if (stage === "prompt_required") {
2629
2780
  return renderGuideCommand("PROMPT", input.apiBaseUrl, input.commandPrefix, {
@@ -2975,7 +3126,21 @@ async function create(argv) {
2975
3126
  if (!references.ok) {
2976
3127
  return references.result;
2977
3128
  }
2978
- return apiRequest({
3129
+ // A live (non-dry-run, authenticated) create is the only branch that spends
3130
+ // credits. Give it a recovery handle BEFORE the blocking request (#1789).
3131
+ const isLiveSpend = !flagBool(args, "dry-run") && token.token !== null;
3132
+ const idempotencyKey = isLiveSpend
3133
+ ? liveSpendIdempotencyKey(args, "create")
3134
+ : flagString(args, "idempotency-key");
3135
+ const inFlight = isLiveSpend
3136
+ ? await recordInFlightSpend({
3137
+ command: "image-skill create",
3138
+ operation: "create",
3139
+ idempotencyKey,
3140
+ argv,
3141
+ })
3142
+ : null;
3143
+ const result = await apiRequest({
2979
3144
  command: "image-skill create",
2980
3145
  method: "POST",
2981
3146
  apiBaseUrl: apiBase(args),
@@ -3010,15 +3175,15 @@ async function create(argv) {
3010
3175
  ...(modelParameters.value === null
3011
3176
  ? {}
3012
3177
  : { model_parameters: modelParameters.value }),
3013
- // Retry-safe dedupe (#1228): when provided, a retry with the same key does
3014
- // not double-charge after a transient 502 that already debited a credit.
3015
- ...(flagString(args, "idempotency-key") === null
3016
- ? {}
3017
- : { idempotency_key: flagString(args, "idempotency-key") }),
3178
+ // Retry-safe dedupe (#1228/#1789): a live create always carries a key so a
3179
+ // retry (or an interrupted-then-recovered run) dedupes to one charge.
3180
+ ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
3018
3181
  dry_run: flagBool(args, "dry-run"),
3019
3182
  accept_unknown_cost: flagBool(args, "accept-unknown-cost"),
3020
3183
  },
3021
3184
  });
3185
+ await clearInFlightSpend(inFlight);
3186
+ return result;
3022
3187
  }
3023
3188
 
3024
3189
  async function upload(argv) {
@@ -3089,7 +3254,21 @@ async function edit(argv) {
3089
3254
  if (!modelParameters.ok) {
3090
3255
  return modelParameters.result;
3091
3256
  }
3092
- return apiRequest({
3257
+ // A live (non-dry-run) edit spends credits; give it a recovery handle BEFORE
3258
+ // the blocking request (#1789). Edit always carries a token.
3259
+ const isLiveSpend = !flagBool(args, "dry-run");
3260
+ const idempotencyKey = isLiveSpend
3261
+ ? liveSpendIdempotencyKey(args, "edit")
3262
+ : flagString(args, "idempotency-key");
3263
+ const inFlight = isLiveSpend
3264
+ ? await recordInFlightSpend({
3265
+ command: "image-skill edit",
3266
+ operation: "edit",
3267
+ idempotencyKey,
3268
+ argv,
3269
+ })
3270
+ : null;
3271
+ const result = await apiRequest({
3093
3272
  command: "image-skill edit",
3094
3273
  method: "POST",
3095
3274
  apiBaseUrl: apiBase(args),
@@ -3122,14 +3301,14 @@ async function edit(argv) {
3122
3301
  ? {}
3123
3302
  : { model_parameters: modelParameters.value }),
3124
3303
  ...(flagBool(args, "dry-run") ? { dry_run: true } : {}),
3125
- // Retry-safe dedupe (#1228): see create same key dedupes a retry that
3126
- // follows a transient 502 which already debited a credit.
3127
- ...(flagString(args, "idempotency-key") === null
3128
- ? {}
3129
- : { idempotency_key: flagString(args, "idempotency-key") }),
3304
+ // Retry-safe dedupe (#1228/#1789): a live edit always carries a key so a
3305
+ // retry (or an interrupted-then-recovered run) dedupes to one charge.
3306
+ ...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
3130
3307
  accept_unknown_cost: flagBool(args, "accept-unknown-cost"),
3131
3308
  },
3132
3309
  });
3310
+ await clearInFlightSpend(inFlight);
3311
+ return result;
3133
3312
  }
3134
3313
 
3135
3314
  async function assets(argv) {
@@ -4206,6 +4385,102 @@ async function fetchPublicText(url, options = {}) {
4206
4385
  }
4207
4386
  }
4208
4387
 
4388
+ // --- In-flight live-spend recovery breadcrumb (#1789) -----------------------
4389
+ // A live create/edit is one synchronous request that can block for the full
4390
+ // provider duration (often minutes). If the agent kills the process during that
4391
+ // wait, the hosted API may have already reserved a credit, yet the agent is left
4392
+ // with no job id, no trace, and no recovery handle — an orphaned debit it cannot
4393
+ // see or reconcile (the reservation only auto-releases on a 15-minute TTL).
4394
+ //
4395
+ // We close that loop on the client, BEFORE the blocking request: every live
4396
+ // spend carries an idempotency key, and we hand the agent that key (stderr) plus
4397
+ // a durable local breadcrumb. On interruption the agent re-runs the same command
4398
+ // with the same key; the hosted API replays the original job (returning the
4399
+ // asset already paid for) or releases the reserved credit — never a double
4400
+ // charge. See https://image-skill.com/cli.md#image-skill-create.
4401
+
4402
+ function liveSpendIdempotencyKey(args, operation) {
4403
+ const explicit = flagString(args, "idempotency-key");
4404
+ if (explicit !== null) {
4405
+ return explicit;
4406
+ }
4407
+ return `${operation}-${Date.now()}-${randomBytes(6).toString("hex")}`;
4408
+ }
4409
+
4410
+ function inFlightSpendDir() {
4411
+ return join(dirname(configPath()), "in-flight");
4412
+ }
4413
+
4414
+ function recoverCommandFor(operation, idempotencyKey) {
4415
+ return `image-skill ${operation} --idempotency-key ${idempotencyKey} <same arguments> --json`;
4416
+ }
4417
+
4418
+ async function recordInFlightSpend(input) {
4419
+ const { command, operation, idempotencyKey, argv } = input;
4420
+ const recoverCommand = recoverCommandFor(operation, idempotencyKey);
4421
+ const note =
4422
+ "live spend may already be reserved. If this command is interrupted before it returns a result, re-run it with the idempotency_key above; the hosted API replays the original job or releases the reserved credit and never double-charges.";
4423
+ // Persist the durable breadcrumb FIRST, then announce — so by the time the
4424
+ // agent sees the stderr handle, the on-disk copy already exists (an operator
4425
+ // or a later session can find an orphaned spend even when the transcript is
4426
+ // gone).
4427
+ const dir = inFlightSpendDir();
4428
+ const path = join(dir, `${idempotencyKey}.json`);
4429
+ let recordedPath = null;
4430
+ try {
4431
+ await mkdir(dir, { recursive: true });
4432
+ await writeFile(
4433
+ path,
4434
+ `${JSON.stringify(
4435
+ {
4436
+ schema: "image-skill.in-flight-spend.v1",
4437
+ command,
4438
+ operation,
4439
+ idempotency_key: idempotencyKey,
4440
+ recover_command: recoverCommand,
4441
+ argv,
4442
+ started_at: new Date().toISOString(),
4443
+ },
4444
+ null,
4445
+ 2,
4446
+ )}\n`,
4447
+ { mode: 0o600 },
4448
+ );
4449
+ recordedPath = path;
4450
+ } catch {
4451
+ // The stderr notice below is the primary handle; a filesystem failure must
4452
+ // not block the create/edit.
4453
+ }
4454
+ // stderr only — the stdout JSON envelope contract is unchanged. Even a killed
4455
+ // process leaves this line in the agent's captured transcript.
4456
+ try {
4457
+ process.stderr.write(
4458
+ `${JSON.stringify({
4459
+ in_flight: {
4460
+ command,
4461
+ idempotency_key: idempotencyKey,
4462
+ recover_command: recoverCommand,
4463
+ note,
4464
+ },
4465
+ })}\n`,
4466
+ );
4467
+ } catch {
4468
+ // diagnostics are best-effort; never block the spend on a write failure.
4469
+ }
4470
+ return recordedPath;
4471
+ }
4472
+
4473
+ async function clearInFlightSpend(path) {
4474
+ if (path === null || path === undefined) {
4475
+ return;
4476
+ }
4477
+ try {
4478
+ await rm(path, { force: true });
4479
+ } catch {
4480
+ // best-effort cleanup; a leftover breadcrumb is harmless.
4481
+ }
4482
+ }
4483
+
4209
4484
  async function apiRequest(input) {
4210
4485
  const url = new URL(input.path, ensureTrailingSlash(input.apiBaseUrl));
4211
4486
  try {