luxin-cli 0.2.9 → 0.2.10

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/bin/luxin.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomBytes, randomInt } from "node:crypto";
3
- import { createWriteStream } from "node:fs";
3
+ import { createWriteStream, fstatSync } from "node:fs";
4
4
  import {
5
5
  chmod,
6
6
  mkdir,
@@ -15,18 +15,21 @@ import { Readable } from "node:stream";
15
15
  import { pipeline } from "node:stream/promises";
16
16
  import os from "node:os";
17
17
 
18
- const VERSION = "0.2.9";
18
+ const VERSION = "0.2.10";
19
19
  // Content-derived identity of the guidance this package ships (#2302). Kept in
20
20
  // sync with docs/public-contract/skill.md by `pnpm skill-revision:write` and
21
21
  // gated by `pnpm skill-revision:check`; the mirror cannot import from src/.
22
- const SKILL_REVISION = "259c47afaeae";
22
+ const SKILL_REVISION = "88f5943969e2";
23
23
  // `luxin skill` prints these bundled bytes rather than fetching them, so the
24
24
  // guidance can never describe a CLI other than the one printing it. Packed by
25
25
  // public-cli/package.json `files`.
26
26
  const SKILL_GUIDANCE_FILE = "skill-core.md";
27
27
  const SKILL_GUIDANCE_SCHEMA = "image-skill.skill-guidance.v1";
28
28
  const SKILL_CORE_URL = "https://luxin.sh/skill-core.md";
29
- const SKILL_REFRESH_COMMAND = "npx skills update luxin";
29
+ // Unfiltered on purpose: `skills update NAME` matches the installed skill
30
+ // name, and most installs carry an alias name (`image-skill`, ...) the CLI
31
+ // cannot see, so a filtered command updated nothing for them.
32
+ const SKILL_REFRESH_COMMAND = "npx skills update";
30
33
  const INSTALLED_SKILL_REVISION_ENV = "LUXIN_SKILL_REVISION";
31
34
  const INSTALLED_SKILL_REVISION_HEADER = "x-luxin-skill-revision";
32
35
  const PACKAGE_NAME = "luxin";
@@ -71,6 +74,15 @@ const MODELS_BOOLEAN_FLAGS = new Set([
71
74
  "token-stdin",
72
75
  ]);
73
76
 
77
+ // Mirrors OPENAI_QUALITY_MEDIUM_DEFAULT_MODEL_IDS in src/hosted-create-policy.ts.
78
+ const CREATE_GUIDE_OPENAI_QUALITY_MEDIUM_DEFAULT_MODEL_IDS = new Set([
79
+ "openai.gpt-image-2",
80
+ "openai.gpt-image-2.5-flare",
81
+ "openai.gpt-image-2.5-flare-2026-09-08",
82
+ "openai.gpt-image-2.5-sunburst",
83
+ "openai.gpt-image-2.5-sunburst-2026-09-08",
84
+ ]);
85
+
74
86
  const MODEL_FIND_DEFAULT_LIMIT = 10;
75
87
  const MODEL_FIND_MAX_LIMIT = 50;
76
88
  const MODEL_FIND_DEFAULT_SORT = "credits";
@@ -283,7 +295,16 @@ const GUIDE_NEXT_COMMAND_PLACEHOLDERS = [
283
295
  effect_description: "Luxin input asset id",
284
296
  example: null,
285
297
  },
298
+ {
299
+ placeholder: "IMAGE_PATH",
300
+ flag: "--reference-image",
301
+ value_description:
302
+ "Starting image this model animates or builds from: a local file path, an https URL, or a Luxin image_... asset id. Without one, pick a text-to-video model instead.",
303
+ effect_description: "starting image the model requires",
304
+ example: "./first-frame.png",
305
+ },
286
306
  ];
307
+ const START_IMAGE_PLACEHOLDER = "IMAGE_PATH";
287
308
 
288
309
  // ---------------------------------------------------------------------------
289
310
  // Explicit aspect-ratio intent (#2213/#2203/#2209, hardened by #2243/#2252).
@@ -375,21 +396,173 @@ function boundOrientationRe(adjective) {
375
396
  );
376
397
  }
377
398
 
399
+ // "wide" is bound like the others, but may skip one descriptive word ("a wide
400
+ // cinematic composition"). It must not fire on "wide-angle" (a lens, not a
401
+ // frame), on "ultra-wide" (handled as 21:9 below), or inside another word.
402
+ const WIDE_BOUND_RE = new RegExp(
403
+ `(?<!ultra[\\s-]?)(?<![\\w-])wide(?![\\s-]+angle)(?:[\\s-]+[a-z]+)?[\\s-]+(?:${ORIENTATION_NOUN})\\b`,
404
+ "i",
405
+ );
406
+
407
+ // `tier`: `orientation` means the brief names the shape ("vertical video",
408
+ // "wide composition"); `usage` means it names a placement whose shape is
409
+ // conventional ("hero banner", "instagram story"). A stated orientation wins
410
+ // over a usage convention, so "a hero image in portrait orientation" is 9:16.
378
411
  const ORIENTATION_RULES = [
379
412
  // "widescreen" and "ultrawide" are unambiguous on their own -- they have no
380
413
  // competing subject/genre meaning.
381
- { re: /\bultra[\s-]?wide(?:screen)?\b/i, ratio: "21:9" },
382
- { re: /\bwide[\s-]?screen\b/i, ratio: "16:9" },
383
- // These four need an orientation noun to bind to.
384
- { re: boundOrientationRe("portrait"), ratio: "9:16" },
385
- { re: boundOrientationRe("vertical"), ratio: "9:16" },
386
- { re: boundOrientationRe("landscape"), ratio: "16:9" },
387
- { re: boundOrientationRe("horizontal"), ratio: "16:9" },
388
- { re: boundOrientationRe("square"), ratio: "1:1" },
414
+ { re: /\bultra[\s-]?wide(?:screen)?\b/i, ratio: "21:9", tier: "orientation" },
415
+ { re: /\bwide[\s-]?screen\b/i, ratio: "16:9", tier: "orientation" },
416
+ // These need an orientation noun to bind to.
417
+ { re: WIDE_BOUND_RE, ratio: "16:9", tier: "orientation" },
418
+ { re: boundOrientationRe("portrait"), ratio: "9:16", tier: "orientation" },
419
+ { re: boundOrientationRe("vertical"), ratio: "9:16", tier: "orientation" },
420
+ { re: boundOrientationRe("landscape"), ratio: "16:9", tier: "orientation" },
421
+ { re: boundOrientationRe("horizontal"), ratio: "16:9", tier: "orientation" },
422
+ { re: boundOrientationRe("square"), ratio: "1:1", tier: "orientation" },
423
+ // #2358: placements whose shape is conventional. "a wide landscape hero
424
+ // banner" stated a wide frame three ways and matched none of the rules
425
+ // above. Each phrase is a placement, never a bare subject word: bare
426
+ // "banner" (heraldic banners are tall) and bare "story" (a narrative) stay
427
+ // out.
428
+ {
429
+ re: /\bhero[\s-]+(?:banner|image|section|header|graphic|background)\b/i,
430
+ ratio: "16:9",
431
+ tier: "usage",
432
+ },
433
+ {
434
+ re: /\b(?:web(?:site)?|site|page|blog)[\s-]+(?:banner|header)\b/i,
435
+ ratio: "16:9",
436
+ tier: "usage",
437
+ },
438
+ { re: /\byoutube[\s-]+thumbnail\b/i, ratio: "16:9", tier: "usage" },
439
+ {
440
+ re: /\bdesktop[\s-]+(?:wallpaper|background)\b/i,
441
+ ratio: "16:9",
442
+ tier: "usage",
443
+ },
444
+ {
445
+ re: /\b(?:phone|mobile|lock[\s-]?screen)[\s-]+(?:wallpaper|background)\b/i,
446
+ ratio: "9:16",
447
+ tier: "usage",
448
+ },
449
+ {
450
+ re: /\b(?:instagram|insta|ig|facebook|snapchat|whatsapp|social(?:[\s-]+media)?)[\s-]+(?:story|stories|reels?)\b/i,
451
+ ratio: "9:16",
452
+ tier: "usage",
453
+ },
454
+ {
455
+ re: /\b(?:tiktok|youtube[\s-]+shorts?)[\s-]+(?:video|clip|format|post)\b/i,
456
+ ratio: "9:16",
457
+ tier: "usage",
458
+ },
459
+ {
460
+ re: /\bstor(?:y|ies)[\s-]+(?:format|size|dimensions|aspect(?:\s+ratio)?|ratio|orientation)\b/i,
461
+ ratio: "9:16",
462
+ tier: "usage",
463
+ },
389
464
  ];
390
465
 
391
466
  const GUIDE_ASPECT_RATIO_PROBE_LIMIT = 8;
392
467
 
468
+ // #2393: the Codex sandbox flag that turns outbound network on.
469
+ const NETWORK_UNAVAILABLE_CODEX_FIX =
470
+ "codex -s workspace-write -c sandbox_workspace_write.network_access=true";
471
+
472
+ const OUTPUT_EXTENSION_BY_MIME_TYPE = new Map([
473
+ ["image/png", ".png"],
474
+ ["image/jpeg", ".jpg"],
475
+ ["image/webp", ".webp"],
476
+ ["image/gif", ".gif"],
477
+ ["image/avif", ".avif"],
478
+ ["video/mp4", ".mp4"],
479
+ ["video/webm", ".webm"],
480
+ ["video/quicktime", ".mov"],
481
+ ["audio/mpeg", ".mp3"],
482
+ ["audio/wav", ".wav"],
483
+ ["audio/x-wav", ".wav"],
484
+ ["model/gltf-binary", ".glb"],
485
+ ]);
486
+
487
+ const CLI_DOWNLOAD_USER_AGENT = `luxin-cli/${VERSION} (+https://luxin.sh/cli.md)`;
488
+
489
+ // Every alias must resolve to an executable row; a retired target makes
490
+ // `--model ALIAS` select nothing. The imagen4 trio was dropped when #2384
491
+ // retired those Fal endpoints: redirecting a named Google model to another
492
+ // vendor would be a silent substitution. tests/model-alias-rules.test.ts.
493
+ const MODEL_ALIAS_RULES = [
494
+ {
495
+ alias: "nano-banana",
496
+ modelId: "fal.nano-banana-2",
497
+ synonyms: [
498
+ "nano banana",
499
+ "fal/nano-banana",
500
+ "fal-ai/nano-banana",
501
+ "fal-ai/nano-banana-2",
502
+ ],
503
+ },
504
+ {
505
+ alias: "nano-banana-edit",
506
+ modelId: "fal.nano-banana-2-edit",
507
+ synonyms: [
508
+ "nano banana edit",
509
+ "nano-banana-2/edit",
510
+ "fal/nano-banana/edit",
511
+ "fal-ai/nano-banana/edit",
512
+ "fal-ai/nano-banana-2/edit",
513
+ ],
514
+ },
515
+ {
516
+ alias: "nano-banana-pro",
517
+ modelId: "fal.nano-banana-pro",
518
+ synonyms: ["nano banana pro", "fal-ai/nano-banana-pro"],
519
+ },
520
+ {
521
+ alias: "nano-banana-pro-edit",
522
+ modelId: "fal.nano-banana-pro-edit",
523
+ synonyms: [
524
+ "nano banana pro edit",
525
+ "nano-banana-pro/edit",
526
+ "fal-ai/nano-banana-pro/edit",
527
+ ],
528
+ },
529
+ {
530
+ alias: "grok-imagine-image-2",
531
+ modelId: "xai.grok-imagine-image-2.0",
532
+ synonyms: [
533
+ "grok imagine image 2",
534
+ "grok-imagine-image-2.0",
535
+ "grok-imagine-2",
536
+ "xai/grok-imagine-image-2.0",
537
+ ],
538
+ },
539
+ {
540
+ alias: "grok-imagine-image-2-edit",
541
+ modelId: "xai.grok-imagine-image-2.0-edit",
542
+ synonyms: [
543
+ "grok imagine image 2 edit",
544
+ "grok-imagine-image-2.0-edit",
545
+ "grok-imagine-image-2.0/edit",
546
+ "xai/grok-imagine-image-2.0/edit",
547
+ ],
548
+ },
549
+ ];
550
+
551
+ // Copy of `preferredHostedDefaultVideoCreateModelIds()` in
552
+ // `src/hosted-create-policy.ts`; the guide differential pins the two.
553
+ const PREFERRED_GUIDE_VIDEO_MODEL_IDS = [
554
+ "fal.ltx-2-3-text-to-video-fast",
555
+ "fal.pixverse-v6-text-to-video",
556
+ "fal.luma-agent-ray-v3-2-text-to-video",
557
+ "fal.minimax-hailuo-02-standard-text-to-video",
558
+ "fal.veo3-1-lite",
559
+ "fal.wan-v2-7-text-to-video",
560
+ "fal.veo3-1-fast",
561
+ "fal.kling-video-v3-standard-text-to-video",
562
+ "fal.bytedance-seedance-2-0-fast-text-to-video",
563
+ "fal.ltx-video-13b-distilled",
564
+ ];
565
+
393
566
  const CREATE_BATCH_SCHEMA = "image-skill.create-batch.v1";
394
567
  const MAX_BATCH_ITEMS = 200;
395
568
  const MAX_BATCH_ID_LENGTH = 100;
@@ -470,7 +643,14 @@ function agentSkillHint(commandPrefix = "luxin") {
470
643
 
471
644
  const argv = normalizePublicArgv(process.argv.slice(2));
472
645
  const result = await main(argv);
473
- process.stdout.write(`${JSON.stringify(result.envelope, null, 2)}\n`);
646
+ // `luxin skill` without --json prints the guidance markdown itself (#2358:
647
+ // the JSON-wrapped copy was 35.8KB and had to be parsed before it could be
648
+ // read). Every other command, and every failure, prints one JSON envelope.
649
+ process.stdout.write(
650
+ typeof result.text === "string"
651
+ ? result.text
652
+ : `${JSON.stringify(result.envelope, null, 2)}\n`,
653
+ );
474
654
  process.exitCode = result.exitCode;
475
655
 
476
656
  async function main(rawArgv) {
@@ -881,9 +1061,12 @@ function commandHelpByKey(key) {
881
1061
  "--no-spend",
882
1062
  "--explain",
883
1063
  "--dry-run",
1064
+ "--prompt-file",
884
1065
  "--model",
885
1066
  "--aspect-ratio",
886
1067
  "--output-count",
1068
+ "--output",
1069
+ "--overwrite",
887
1070
  "--element-frontal",
888
1071
  "--element-reference",
889
1072
  "--reference-image",
@@ -907,6 +1090,7 @@ function commandHelpByKey(key) {
907
1090
  "--guide",
908
1091
  "--dry-run",
909
1092
  "--prompt",
1093
+ "--prompt-file",
910
1094
  "--model",
911
1095
  "--mask",
912
1096
  "--element-reference",
@@ -914,6 +1098,8 @@ function commandHelpByKey(key) {
914
1098
  "--reference-image",
915
1099
  "--model-parameters-json",
916
1100
  "--idempotency-key",
1101
+ "--output",
1102
+ "--overwrite",
917
1103
  ],
918
1104
  },
919
1105
  assets: {
@@ -1075,7 +1261,7 @@ async function skill(argv) {
1075
1261
  }
1076
1262
 
1077
1263
  const refresh = skillRefresh();
1078
- return success(
1264
+ const structured = success(
1079
1265
  "luxin skill",
1080
1266
  {
1081
1267
  schema: SKILL_GUIDANCE_SCHEMA,
@@ -1096,6 +1282,11 @@ async function skill(argv) {
1096
1282
  },
1097
1283
  refresh === null ? [] : [skillRefreshWarning(refresh)],
1098
1284
  );
1285
+ // Markdown is the default: the bytes an agent reads, with nothing to
1286
+ // unwrap. `--json` prints the envelope (revision, bytes, refresh fields).
1287
+ return flagBool(args, "json")
1288
+ ? structured
1289
+ : { ...structured, text: skillMarkdownWithRefreshNote(content, refresh) };
1099
1290
  }
1100
1291
 
1101
1292
  /**
@@ -1129,6 +1320,13 @@ function skillRefresh() {
1129
1320
  };
1130
1321
  }
1131
1322
 
1323
+ function skillMarkdownWithRefreshNote(content, refresh) {
1324
+ const body = content.endsWith("\n") ? content : `${content}\n`;
1325
+ return refresh === null
1326
+ ? body
1327
+ : `> Note: ${skillRefreshWarning(refresh)}\n\n${body}`;
1328
+ }
1329
+
1132
1330
  function skillRefreshWarning(refresh) {
1133
1331
  return `installed skill revision ${refresh.installed_revision} does not match the current revision ${refresh.current_revision} this CLI ships; refresh with: ${refresh.refresh_command}`;
1134
1332
  }
@@ -2990,7 +3188,16 @@ async function createGuide(args, options = {}) {
2990
3188
  }
2991
3189
 
2992
3190
  const apiBaseUrl = apiBase(args);
2993
- const prompt = flagString(args, "prompt") ?? args.positionals[0] ?? "";
3191
+ // #2371: the guide read --prompt only, so --prompt-file reported
3192
+ // prompt_required while the same text inline was ready_to_create.
3193
+ const promptInput = await readPromptFlags(args, {
3194
+ command,
3195
+ allowPositional: true,
3196
+ });
3197
+ if (!promptInput.ok) {
3198
+ return promptInput.result;
3199
+ }
3200
+ const prompt = promptInput.prompt ?? "";
2994
3201
  const trimmedPrompt = prompt.trim();
2995
3202
  const requestedModelId = flagString(args, "model");
2996
3203
  const requestedProviderId = flagString(args, "provider");
@@ -3195,6 +3402,7 @@ async function createGuide(args, options = {}) {
3195
3402
  requestedModelId,
3196
3403
  quota,
3197
3404
  estimatedCredits,
3405
+ health,
3198
3406
  });
3199
3407
  // #2204-#2216: `--explain` opts back into the complete payload in every
3200
3408
  // stage; the compact default is for the agent that just wants the decision.
@@ -3578,16 +3786,31 @@ async function createGuide(args, options = {}) {
3578
3786
  for (const field of compactOmittedFields) {
3579
3787
  delete guideData[field];
3580
3788
  }
3581
- return createGuideSuccess(command, quota?.envelope.actor ?? null, guideData, [
3582
- ...(plannedDebitUnavailable
3583
- ? [
3584
- "create_credit_debit_unavailable: this hosted API does not publish per-aspect-ratio create debits, so the credits above are the model's default-shape quote and may not be what create debits. Run the dry_run command in escape_hatches for the exact figure before budgeting.",
3585
- ]
3586
- : []),
3587
- ...(guideSkillRefresh === null
3588
- ? []
3589
- : [skillRefreshWarning(guideSkillRefresh)]),
3590
- ]);
3789
+ // #2371: replay `--prompt-file` and `--output` instead of inlining the text
3790
+ // or dropping the destination.
3791
+ const replayedGuideData = replayOutputInGuideNextCommand(
3792
+ replayPromptFileInCommands(guideData, {
3793
+ prompt: trimmedPrompt,
3794
+ promptFile: promptInput.promptFile,
3795
+ }),
3796
+ flagString(args, "output"),
3797
+ flagBool(args, "overwrite"),
3798
+ );
3799
+ return createGuideSuccess(
3800
+ command,
3801
+ quota?.envelope.actor ?? null,
3802
+ replayedGuideData,
3803
+ [
3804
+ ...(plannedDebitUnavailable
3805
+ ? [
3806
+ "create_credit_debit_unavailable: this hosted API does not publish per-aspect-ratio create debits, so the credits above are the model's default-shape quote and may not be what create debits. Run the dry_run command in escape_hatches for the exact figure before budgeting.",
3807
+ ]
3808
+ : []),
3809
+ ...(guideSkillRefresh === null
3810
+ ? []
3811
+ : [skillRefreshWarning(guideSkillRefresh)]),
3812
+ ],
3813
+ );
3591
3814
  }
3592
3815
 
3593
3816
  function createGuideSuccess(command, actor, data, warnings = []) {
@@ -3667,13 +3890,10 @@ function selectCreateGuideModel(
3667
3890
  }
3668
3891
  }
3669
3892
  if (createGuideImpliesVideo({ prompt, intent })) {
3670
- const videoCandidates = eligible.filter(
3671
- (model) => model?.modality === "video",
3672
- );
3673
- const video =
3674
- videoCandidates.find(
3675
- (model) => !modelCannotExpressAspectRatio(model, requestedRatio),
3676
- ) ?? videoCandidates[0];
3893
+ const video = selectGuideVideoModel({
3894
+ candidates: eligible.filter((model) => model?.modality === "video"),
3895
+ requestedAspectRatio: requestedRatio,
3896
+ });
3677
3897
  if (video !== undefined) {
3678
3898
  return chosen(video);
3679
3899
  }
@@ -3736,6 +3956,30 @@ function selectCreateGuideModel(
3736
3956
  // capability UNKNOWN, which never disqualifies a model and never triggers the
3737
3957
  // unsupported-ratio warning.
3738
3958
 
3959
+ // #2357: prompt-only video models first (the guide has no starting image to
3960
+ // give an image-to-video model), then the curated preference order, then the
3961
+ // first that can express a requested ratio. Mirrors `selectGuideVideoModel`
3962
+ // in `src/commands.ts`.
3963
+ function selectGuideVideoModel({ candidates, requestedAspectRatio }) {
3964
+ const promptOnly = candidates.filter(
3965
+ (model) => !createGuideCreateModelRequiresInputImage(model),
3966
+ );
3967
+ const pool = promptOnly.length > 0 ? promptOnly : candidates;
3968
+ const ordered = [
3969
+ ...PREFERRED_GUIDE_VIDEO_MODEL_IDS.map((modelId) =>
3970
+ pool.find((model) => model?.id === modelId),
3971
+ ).filter((model) => model !== undefined),
3972
+ ...pool.filter(
3973
+ (model) => !PREFERRED_GUIDE_VIDEO_MODEL_IDS.includes(model?.id),
3974
+ ),
3975
+ ];
3976
+ return (
3977
+ ordered.find(
3978
+ (model) => !modelCannotExpressAspectRatio(model, requestedAspectRatio),
3979
+ ) ?? ordered[0]
3980
+ );
3981
+ }
3982
+
3739
3983
  function guideAspectRatioProbeModelIds({ models, selected, intent, routing }) {
3740
3984
  const ids = [];
3741
3985
  if (typeof selected?.id === "string") {
@@ -3745,9 +3989,9 @@ function guideAspectRatioProbeModelIds({ models, selected, intent, routing }) {
3745
3989
  // sole open question is what the current pick can express, so asking about
3746
3990
  // the alternatives would be a round trip spent on an answer nothing reads.
3747
3991
  if (routing) {
3748
- for (const modelId of preferredCreateGuideModelIds(
3749
- createGuideIntentClass(intent),
3750
- )) {
3992
+ for (const modelId of selected?.modality === "video"
3993
+ ? PREFERRED_GUIDE_VIDEO_MODEL_IDS
3994
+ : preferredCreateGuideModelIds(createGuideIntentClass(intent))) {
3751
3995
  if (models.some((model) => model?.id === modelId)) {
3752
3996
  ids.push(modelId);
3753
3997
  }
@@ -3815,59 +4059,6 @@ async function guideModelsWithAspectRatioCapabilities({
3815
4059
  });
3816
4060
  }
3817
4061
 
3818
- const MODEL_ALIAS_RULES = [
3819
- {
3820
- alias: "nano-banana",
3821
- modelId: "fal.nano-banana-2",
3822
- synonyms: [
3823
- "nano banana",
3824
- "fal/nano-banana",
3825
- "fal-ai/nano-banana",
3826
- "fal-ai/nano-banana-2",
3827
- ],
3828
- },
3829
- {
3830
- alias: "nano-banana-edit",
3831
- modelId: "fal.nano-banana-2-edit",
3832
- synonyms: [
3833
- "nano banana edit",
3834
- "nano-banana-2/edit",
3835
- "fal/nano-banana/edit",
3836
- "fal-ai/nano-banana/edit",
3837
- "fal-ai/nano-banana-2/edit",
3838
- ],
3839
- },
3840
- {
3841
- alias: "nano-banana-pro",
3842
- modelId: "fal.nano-banana-pro",
3843
- synonyms: ["nano banana pro", "fal-ai/nano-banana-pro"],
3844
- },
3845
- {
3846
- alias: "nano-banana-pro-edit",
3847
- modelId: "fal.nano-banana-pro-edit",
3848
- synonyms: [
3849
- "nano banana pro edit",
3850
- "nano-banana-pro/edit",
3851
- "fal-ai/nano-banana-pro/edit",
3852
- ],
3853
- },
3854
- {
3855
- alias: "imagen4",
3856
- modelId: "fal.imagen4-preview",
3857
- synonyms: ["imagen 4", "fal-ai/imagen4/preview"],
3858
- },
3859
- {
3860
- alias: "imagen4-fast",
3861
- modelId: "fal.imagen4-preview-fast",
3862
- synonyms: ["imagen 4 fast", "fal-ai/imagen4/preview/fast"],
3863
- },
3864
- {
3865
- alias: "imagen4-ultra",
3866
- modelId: "fal.imagen4-preview-ultra",
3867
- synonyms: ["imagen 4 ultra", "fal-ai/imagen4/preview/ultra"],
3868
- },
3869
- ];
3870
-
3871
4062
  function resolveModelLookup(requestedModelId, models) {
3872
4063
  const requested = String(requestedModelId ?? "").trim();
3873
4064
  if (requested.length === 0) {
@@ -4070,7 +4261,7 @@ function createGuideDefaultModelParameters(input) {
4070
4261
  }
4071
4262
 
4072
4263
  if (
4073
- input.model?.id === "openai.gpt-image-2" &&
4264
+ CREATE_GUIDE_OPENAI_QUALITY_MEDIUM_DEFAULT_MODEL_IDS.has(input.model?.id) &&
4074
4265
  modelParameters.quality === undefined
4075
4266
  ) {
4076
4267
  const mediumPricing = createGuidePricingForModel(
@@ -4394,15 +4585,22 @@ function classifyRequestedAspectRatio(input) {
4394
4585
  return { aspectRatio: null, signal: null };
4395
4586
  }
4396
4587
 
4397
- const implied = new Set();
4398
- for (const rule of ORIENTATION_RULES) {
4399
- if (rule.re.test(text)) {
4400
- implied.add(rule.ratio);
4588
+ // A stated orientation outranks a usage convention; within a tier,
4589
+ // disagreement means the brief is ambiguous and nothing is suggested.
4590
+ for (const tier of ["orientation", "usage"]) {
4591
+ const implied = new Set();
4592
+ for (const rule of ORIENTATION_RULES) {
4593
+ if (rule.tier === tier && rule.re.test(text)) {
4594
+ implied.add(rule.ratio);
4595
+ }
4596
+ }
4597
+ if (implied.size === 1) {
4598
+ const [only] = [...implied];
4599
+ return { aspectRatio: only ?? null, signal: "orientation_phrase" };
4600
+ }
4601
+ if (implied.size > 1) {
4602
+ return { aspectRatio: null, signal: null };
4401
4603
  }
4402
- }
4403
- if (implied.size === 1) {
4404
- const [only] = [...implied];
4405
- return { aspectRatio: only ?? null, signal: "orientation_phrase" };
4406
4604
  }
4407
4605
  return { aspectRatio: null, signal: null };
4408
4606
  }
@@ -4529,6 +4727,23 @@ function createGuideSelectedModelRequiresInputImage(model) {
4529
4727
  );
4530
4728
  }
4531
4729
 
4730
+ // #2357: image-to-video models publish `supports: ["create"]` but cannot run
4731
+ // without a starting image. The compact `/v1/models` row carries that fact as
4732
+ // the "input-image" task tag (derived from `media.input.images.required`), so
4733
+ // read the tag, and `media` only when a detail payload hydrated it.
4734
+ function createGuideCreateModelRequiresInputImage(model) {
4735
+ return (
4736
+ !createGuideSelectedModelRequiresInputImage(model) &&
4737
+ Array.isArray(model?.supports) &&
4738
+ model.supports.includes("create") &&
4739
+ ((Array.isArray(model?.task_tags) &&
4740
+ (model.task_tags.includes("input-image") ||
4741
+ model.task_tags.includes("reference-required"))) ||
4742
+ model?.media?.input?.images?.required === true ||
4743
+ model?.media?.input?.references?.required === true)
4744
+ );
4745
+ }
4746
+
4532
4747
  function createGuideRequestedSelectionReason(model) {
4533
4748
  if (createGuideSelectedModelRequiresInputImage(model)) {
4534
4749
  if (model.modality === "3d") {
@@ -5257,11 +5472,22 @@ function createGuideBlocker(stage, input) {
5257
5472
  message: `Selected first image requires ${input.estimatedCredits ?? "unknown"} credits; current remaining credits are ${remaining ?? "unknown"}.`,
5258
5473
  };
5259
5474
  }
5475
+ // #2393: no outbound network is the common cause in agent sandboxes, and
5476
+ // the fix is a sandbox flag, not a retry. Say so in the blocker itself.
5477
+ const networkError =
5478
+ input.health?.envelope.error?.code === "NETWORK_UNAVAILABLE" ||
5479
+ input.health?.envelope.error?.code === "NETWORK_ERROR"
5480
+ ? input.health.envelope.error
5481
+ : null;
5260
5482
  return {
5261
5483
  code: "service_unreachable",
5262
5484
  message:
5485
+ networkError?.message ??
5263
5486
  input.quota?.envelope.error?.message ??
5264
5487
  "Guide could not complete read-only hosted or registry checks.",
5488
+ ...(networkError?.recovery === undefined
5489
+ ? {}
5490
+ : { recovery: networkError.recovery }),
5265
5491
  };
5266
5492
  }
5267
5493
 
@@ -5991,6 +6217,9 @@ function createGuideNextCommand(stage, input) {
5991
6217
  idempotencyKey: `create-guide-${Date.now()}-${randomBytes(4).toString("hex")}`,
5992
6218
  apiBaseUrl: input.apiBaseUrl,
5993
6219
  commandPrefix: input.commandPrefix,
6220
+ requiresStartImage: createGuideCreateModelRequiresInputImage(
6221
+ input.selected,
6222
+ ),
5994
6223
  });
5995
6224
  }
5996
6225
 
@@ -6048,6 +6277,9 @@ function createGuideEscapeHatches(input) {
6048
6277
  dryRun: true,
6049
6278
  apiBaseUrl: input.apiBaseUrl,
6050
6279
  commandPrefix: input.commandPrefix,
6280
+ requiresStartImage: createGuideCreateModelRequiresInputImage(
6281
+ input.selected,
6282
+ ),
6051
6283
  }),
6052
6284
  };
6053
6285
  }
@@ -6196,6 +6428,10 @@ function renderCreateCommand(input) {
6196
6428
  : ["--provider", shellQuote(input.providerId)]),
6197
6429
  "--model",
6198
6430
  shellQuote(input.modelId),
6431
+ // #2357: the model creates from a starting image.
6432
+ ...(input.requiresStartImage === true
6433
+ ? ["--reference-image", START_IMAGE_PLACEHOLDER]
6434
+ : []),
6199
6435
  "--prompt",
6200
6436
  shellQuote(input.prompt),
6201
6437
  "--intent",
@@ -6337,6 +6573,16 @@ async function create(argv) {
6337
6573
  if (!prompt.ok) {
6338
6574
  return prompt.result;
6339
6575
  }
6576
+ const output = createOutputFlag(args, "luxin create");
6577
+ if (!output.ok) {
6578
+ return output.result;
6579
+ }
6580
+ if (output.path !== null && !flagBool(args, "dry-run")) {
6581
+ const blocked = await outputPathBlocked(output, "luxin create");
6582
+ if (blocked !== null) {
6583
+ return blocked;
6584
+ }
6585
+ }
6340
6586
  let referenceToken = null;
6341
6587
  if (flagBool(args, "dry-run") && hasReferenceFlags(args)) {
6342
6588
  referenceToken = await resolveToken(args);
@@ -6443,10 +6689,21 @@ async function create(argv) {
6443
6689
  commandPrefix,
6444
6690
  );
6445
6691
  await clearInFlightSpendForResult(inFlight, result);
6446
- return withLiveSpendRecoveryHandle(result, {
6447
- operation: "create",
6448
- idempotencyKey: isLiveSpend ? idempotencyKey : null,
6449
- });
6692
+ return withCreateOutputDownloads(
6693
+ withDefaultAspectRatioNotice(
6694
+ withLiveSpendRecoveryHandle(result, {
6695
+ operation: "create",
6696
+ idempotencyKey: isLiveSpend ? idempotencyKey : null,
6697
+ }),
6698
+ {
6699
+ aspectRatioFlag: flagString(args, "aspect-ratio"),
6700
+ plannedAspectRatio: flagString(args, "aspect-ratio") ?? "1:1",
6701
+ prompt: prompt.value,
6702
+ intent: flagString(args, "intent"),
6703
+ },
6704
+ ),
6705
+ { output, dryRun: flagBool(args, "dry-run") },
6706
+ );
6450
6707
  }
6451
6708
 
6452
6709
  // ---------------------------------------------------------------------------
@@ -7875,6 +8132,16 @@ async function edit(argv) {
7875
8132
  if (!prompt.ok) {
7876
8133
  return prompt.result;
7877
8134
  }
8135
+ const output = createOutputFlag(args, "luxin edit");
8136
+ if (!output.ok) {
8137
+ return output.result;
8138
+ }
8139
+ if (output.path !== null && !flagBool(args, "dry-run")) {
8140
+ const blocked = await outputPathBlocked(output, "luxin edit");
8141
+ if (blocked !== null) {
8142
+ return blocked;
8143
+ }
8144
+ }
7878
8145
  const token = await resolveToken(args);
7879
8146
  if (!token.ok) {
7880
8147
  return token.result;
@@ -7970,10 +8237,328 @@ async function edit(argv) {
7970
8237
  commandPrefix,
7971
8238
  );
7972
8239
  await clearInFlightSpendForResult(inFlight, result);
7973
- return withLiveSpendRecoveryHandle(result, {
7974
- operation: "edit",
7975
- idempotencyKey: isLiveSpend ? idempotencyKey : null,
8240
+ return withCreateOutputDownloads(
8241
+ withLiveSpendRecoveryHandle(result, {
8242
+ operation: "edit",
8243
+ idempotencyKey: isLiveSpend ? idempotencyKey : null,
8244
+ }),
8245
+ { output, dryRun: flagBool(args, "dry-run") },
8246
+ );
8247
+ }
8248
+
8249
+ // ---------------------------------------------------------------------------
8250
+ // `--output PATH` / `--overwrite` on create and edit (save-to-file report:
8251
+ // agents asked for ./hero.png needed curl, ffmpeg, and a Python retry that
8252
+ // media.luxin.sh's CDN refused). Mirrors `src/cli-local-output.ts`; keep the
8253
+ // two in step.
8254
+ // ---------------------------------------------------------------------------
8255
+
8256
+ function outputExtensionForMimeType(mimeType) {
8257
+ const normalized =
8258
+ typeof mimeType === "string"
8259
+ ? mimeType.split(";")[0].trim().toLowerCase()
8260
+ : null;
8261
+ return normalized === null
8262
+ ? null
8263
+ : (OUTPUT_EXTENSION_BY_MIME_TYPE.get(normalized) ?? null);
8264
+ }
8265
+
8266
+ function outputExtensionFromUrl(url) {
8267
+ try {
8268
+ const extension = extname(new URL(url).pathname).toLowerCase();
8269
+ return /^\.[a-z0-9]{1,10}$/.test(extension) ? extension : null;
8270
+ } catch {
8271
+ return null;
8272
+ }
8273
+ }
8274
+
8275
+ function canonicalOutputExtension(extension) {
8276
+ const lower = extension.toLowerCase();
8277
+ return lower === ".jpeg" || lower === ".jpe" ? ".jpg" : lower;
8278
+ }
8279
+
8280
+ // The extension is the asset's TRUE type: a request for hero.png that gets
8281
+ // JPEG bytes writes hero.jpg and says so, never a mislabelled hero.png.
8282
+ function resolveOutputPath({ requestedPath, index, trueExtension }) {
8283
+ const requestedExtension = extname(requestedPath);
8284
+ const stem =
8285
+ requestedExtension.length === 0
8286
+ ? requestedPath
8287
+ : requestedPath.slice(0, -requestedExtension.length);
8288
+ const numberedStem = index === 0 ? stem : `${stem}-${index + 1}`;
8289
+ if (trueExtension === null) {
8290
+ return {
8291
+ outputPath: `${numberedStem}${requestedExtension}`,
8292
+ extensionCorrected: false,
8293
+ };
8294
+ }
8295
+ const matches =
8296
+ canonicalOutputExtension(requestedExtension) ===
8297
+ canonicalOutputExtension(trueExtension);
8298
+ return {
8299
+ outputPath: `${numberedStem}${matches ? requestedExtension : trueExtension}`,
8300
+ extensionCorrected: !matches,
8301
+ };
8302
+ }
8303
+
8304
+ function createOutputFlag(args, command) {
8305
+ const overwrite = flagBool(args, "overwrite");
8306
+ if (!args.flags.has("output")) {
8307
+ return overwrite
8308
+ ? {
8309
+ ok: false,
8310
+ result: invalid(
8311
+ command,
8312
+ "--overwrite only applies with --output PATH",
8313
+ ),
8314
+ }
8315
+ : { ok: true, path: null, overwrite };
8316
+ }
8317
+ const path = flagString(args, "output");
8318
+ if (path === null || path.trim().length === 0) {
8319
+ return {
8320
+ ok: false,
8321
+ result: invalid(command, "--output requires a file path"),
8322
+ };
8323
+ }
8324
+ return { ok: true, path: path.trim(), overwrite };
8325
+ }
8326
+
8327
+ // Refuse before spend when the requested path is already taken.
8328
+ async function outputPathBlocked(output, command) {
8329
+ if (output.overwrite || !(await fileExists(output.path))) {
8330
+ return null;
8331
+ }
8332
+ return failure(
8333
+ command,
8334
+ 9,
8335
+ "OUTPUT_EXISTS",
8336
+ `--output path already exists: ${output.path}; pass --overwrite to replace it, or choose another path. Nothing was created.`,
8337
+ false,
8338
+ );
8339
+ }
8340
+
8341
+ function failedOutputDownload(input) {
8342
+ return {
8343
+ asset_id: input.assetId,
8344
+ url: input.url,
8345
+ requested_output_path: input.requestedPath,
8346
+ output_path: input.outputPath,
8347
+ status: "failed",
8348
+ bytes: null,
8349
+ content_type: null,
8350
+ extension_corrected: false,
8351
+ error: { code: input.code, message: input.message },
8352
+ recovery_command: `luxin assets get ${input.assetId ?? input.url} --output ${JSON.stringify(input.outputPath)} --json`,
8353
+ };
8354
+ }
8355
+
8356
+ // Never throws: a failed download leaves the paid result intact and reports a
8357
+ // copy-runnable `assets get` recovery instead.
8358
+ async function downloadCreateOutputs({ assets, requestedPath, overwrite }) {
8359
+ const downloadable = (Array.isArray(assets) ? assets : []).filter(
8360
+ (asset) =>
8361
+ typeof asset?.url === "string" &&
8362
+ asset.url.length > 0 &&
8363
+ asset.planned !== true &&
8364
+ asset.placeholder !== true &&
8365
+ asset.downloadable !== false,
8366
+ );
8367
+ const warnings = [];
8368
+ if (downloadable.length === 0) {
8369
+ warnings.push(
8370
+ "--output wrote nothing: the result has no downloadable asset yet. If a job id is present, run luxin jobs wait JOB_ID --json and then luxin assets get ASSET_ID --output PATH --json.",
8371
+ );
8372
+ return { downloads: [], warnings };
8373
+ }
8374
+ const downloads = [];
8375
+ for (const [index, asset] of downloadable.entries()) {
8376
+ const assetId = typeof asset.asset_id === "string" ? asset.asset_id : null;
8377
+ const declaredMime =
8378
+ typeof asset.mime_type === "string" ? asset.mime_type : null;
8379
+ let response;
8380
+ try {
8381
+ response = await fetch(asset.url, {
8382
+ headers: { "user-agent": CLI_DOWNLOAD_USER_AGENT },
8383
+ });
8384
+ } catch (error) {
8385
+ const planned = resolveOutputPath({
8386
+ requestedPath,
8387
+ index,
8388
+ trueExtension:
8389
+ outputExtensionForMimeType(declaredMime) ??
8390
+ outputExtensionFromUrl(asset.url),
8391
+ });
8392
+ downloads.push(
8393
+ failedOutputDownload({
8394
+ assetId,
8395
+ url: asset.url,
8396
+ requestedPath,
8397
+ outputPath: planned.outputPath,
8398
+ code: "ASSET_DOWNLOAD_FAILED",
8399
+ message: `download failed: ${error instanceof Error ? error.message : "network error"}`,
8400
+ }),
8401
+ );
8402
+ continue;
8403
+ }
8404
+ const contentType =
8405
+ response.headers.get("content-type")?.split(";")[0]?.trim() ?? null;
8406
+ const resolved = resolveOutputPath({
8407
+ requestedPath,
8408
+ index,
8409
+ trueExtension:
8410
+ outputExtensionForMimeType(contentType) ??
8411
+ outputExtensionForMimeType(declaredMime) ??
8412
+ outputExtensionFromUrl(asset.url),
8413
+ });
8414
+ if (!response.ok) {
8415
+ downloads.push(
8416
+ failedOutputDownload({
8417
+ assetId,
8418
+ url: asset.url,
8419
+ requestedPath,
8420
+ outputPath: resolved.outputPath,
8421
+ code: "ASSET_DOWNLOAD_FAILED",
8422
+ message: `download failed with HTTP ${response.status}`,
8423
+ }),
8424
+ );
8425
+ continue;
8426
+ }
8427
+ let bytes;
8428
+ try {
8429
+ bytes = Buffer.from(await response.arrayBuffer());
8430
+ } catch (error) {
8431
+ // The connection can drop after the headers arrive. The media is paid
8432
+ // for and stored, so report a failed download with its recovery
8433
+ // instead of losing the whole result.
8434
+ downloads.push(
8435
+ failedOutputDownload({
8436
+ assetId,
8437
+ url: asset.url,
8438
+ requestedPath,
8439
+ outputPath: resolved.outputPath,
8440
+ code: "ASSET_DOWNLOAD_FAILED",
8441
+ message: `download interrupted: ${error instanceof Error ? error.message : "body read failed"}`,
8442
+ }),
8443
+ );
8444
+ continue;
8445
+ }
8446
+ try {
8447
+ await mkdir(dirname(resolve(resolved.outputPath)), { recursive: true });
8448
+ await writeFile(resolved.outputPath, bytes, {
8449
+ flag: overwrite ? "w" : "wx",
8450
+ });
8451
+ } catch (error) {
8452
+ const code = error?.code ?? "write_failed";
8453
+ downloads.push(
8454
+ failedOutputDownload({
8455
+ assetId,
8456
+ url: asset.url,
8457
+ requestedPath,
8458
+ outputPath: resolved.outputPath,
8459
+ code: code === "EEXIST" ? "OUTPUT_EXISTS" : "OUTPUT_WRITE_FAILED",
8460
+ message:
8461
+ code === "EEXIST"
8462
+ ? `output file already exists: ${resolved.outputPath}; pass --overwrite to replace it`
8463
+ : `could not write ${resolved.outputPath} (${code})`,
8464
+ }),
8465
+ );
8466
+ continue;
8467
+ }
8468
+ if (resolved.extensionCorrected) {
8469
+ warnings.push(
8470
+ `--output asked for ${requestedPath} but the asset is ${contentType ?? declaredMime ?? "a different type"}; wrote ${resolved.outputPath} so the extension matches the bytes. Convert it yourself if you need the other format.`,
8471
+ );
8472
+ }
8473
+ downloads.push({
8474
+ asset_id: assetId,
8475
+ url: asset.url,
8476
+ requested_output_path: requestedPath,
8477
+ output_path: resolved.outputPath,
8478
+ status: "written",
8479
+ bytes: bytes.byteLength,
8480
+ content_type: contentType ?? declaredMime,
8481
+ extension_corrected: resolved.extensionCorrected,
8482
+ error: null,
8483
+ recovery_command: null,
8484
+ });
8485
+ }
8486
+ const failed = downloads.filter((download) => download.status === "failed");
8487
+ if (failed.length > 0) {
8488
+ warnings.push(
8489
+ `--output: ${failed.length} of ${downloads.length} download(s) failed after the media was created; nothing was charged twice. Retry with the recovery_command in data.downloads.`,
8490
+ );
8491
+ }
8492
+ return { downloads, warnings };
8493
+ }
8494
+
8495
+ function appendResultWarnings(result, warnings) {
8496
+ const existing = Array.isArray(result.envelope.warnings)
8497
+ ? result.envelope.warnings
8498
+ : [];
8499
+ return {
8500
+ ...result,
8501
+ envelope: {
8502
+ ...result.envelope,
8503
+ warnings: [
8504
+ ...existing,
8505
+ ...warnings.filter((warning) => !existing.includes(warning)),
8506
+ ],
8507
+ },
8508
+ };
8509
+ }
8510
+
8511
+ async function withCreateOutputDownloads(result, { output, dryRun }) {
8512
+ if (output.path === null || result.envelope?.ok !== true) {
8513
+ return result;
8514
+ }
8515
+ const data = result.envelope.data;
8516
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
8517
+ return result;
8518
+ }
8519
+ if (dryRun) {
8520
+ return appendResultWarnings(
8521
+ {
8522
+ ...result,
8523
+ envelope: { ...result.envelope, data: { ...data, downloads: [] } },
8524
+ },
8525
+ [
8526
+ "--output wrote nothing: a dry run plans the job without producing media. Run the same command without --dry-run to save the file.",
8527
+ ],
8528
+ );
8529
+ }
8530
+ const { downloads, warnings } = await downloadCreateOutputs({
8531
+ assets: data.assets,
8532
+ requestedPath: output.path,
8533
+ overwrite: output.overwrite,
8534
+ });
8535
+ return appendResultWarnings(
8536
+ {
8537
+ ...result,
8538
+ envelope: { ...result.envelope, data: { ...data, downloads } },
8539
+ },
8540
+ warnings,
8541
+ );
8542
+ }
8543
+
8544
+ // #2358: name the planned default ratio, and the ratio the brief implies when
8545
+ // it differs. Mirrors `withDefaultAspectRatioNotice` in `src/commands.ts`.
8546
+ function withDefaultAspectRatioNotice(
8547
+ result,
8548
+ { aspectRatioFlag, plannedAspectRatio, prompt, intent },
8549
+ ) {
8550
+ if (aspectRatioFlag !== null || result.envelope?.ok !== true) {
8551
+ return result;
8552
+ }
8553
+ const implied = requestedAspectRatioFromBrief({
8554
+ prompt: prompt ?? "",
8555
+ intent,
7976
8556
  });
8557
+ return appendResultWarnings(result, [
8558
+ implied !== null && implied !== plannedAspectRatio
8559
+ ? `no --aspect-ratio was passed, so this planned the ${plannedAspectRatio} default; the prompt asks for ${implied}. Re-run with --aspect-ratio ${implied} to get that shape.`
8560
+ : `no --aspect-ratio was passed, so this planned the ${plannedAspectRatio} default. Pass --aspect-ratio (for example 16:9 or 9:16) to choose the shape.`,
8561
+ ]);
7977
8562
  }
7978
8563
 
7979
8564
  async function assets(argv) {
@@ -9154,9 +9739,17 @@ function withLiveSpendRecoveryHandle(result, input) {
9154
9739
  recovery: {
9155
9740
  ...recovery,
9156
9741
  idempotency_key: recovery.idempotency_key ?? idempotencyKey,
9157
- recover_command:
9158
- recovery.recover_command ??
9159
- recoverCommandFor(operation, idempotencyKey),
9742
+ // A non-retryable failure (BAD_REQUEST, MODERATION_BLOCKED,
9743
+ // IMAGE_TOO_SMALL, PROVIDER_VALIDATION_ERROR) is fixed by changing
9744
+ // the request, so a "re-run with the same arguments" command would
9745
+ // contradict `recovery.action`. Keep the key, skip the command.
9746
+ ...(error.retryable === false
9747
+ ? {}
9748
+ : {
9749
+ recover_command:
9750
+ recovery.recover_command ??
9751
+ recoverCommandFor(operation, idempotencyKey),
9752
+ }),
9160
9753
  },
9161
9754
  },
9162
9755
  },
@@ -9375,6 +9968,15 @@ async function recordInFlightSpend(input) {
9375
9968
  // stderr only — stdout carries exactly one JSON document per invocation
9376
9969
  // (#2240/#2311), so this diagnostic must never join it. Even a killed
9377
9970
  // process leaves this line in the agent's captured transcript.
9971
+ //
9972
+ // #2376: an agent that runs `luxin create --json 2>&1` merges the two
9973
+ // streams, and this line then reaches its JSON parser as a second document
9974
+ // ("Extra data: line 2"). When stdout and stderr are the same non-terminal
9975
+ // file or pipe, skip it: the on-disk record above and the envelope's
9976
+ // `idempotency_key` / recovery block carry the same handle.
9977
+ if (stdoutAndStderrShareTarget()) {
9978
+ return recordedPath;
9979
+ }
9378
9980
  try {
9379
9981
  process.stderr.write(
9380
9982
  `${JSON.stringify({
@@ -9392,6 +9994,22 @@ async function recordInFlightSpend(input) {
9392
9994
  return recordedPath;
9393
9995
  }
9394
9996
 
9997
+ // True when fd 1 and fd 2 are the same pipe or file (`2>&1`) and stderr is not
9998
+ // an interactive terminal. A terminal shows both streams to a human, who is
9999
+ // not parsing JSON, so the notice stays there.
10000
+ function stdoutAndStderrShareTarget() {
10001
+ try {
10002
+ if (process.stderr.isTTY) {
10003
+ return false;
10004
+ }
10005
+ const out = fstatSync(1);
10006
+ const err = fstatSync(2);
10007
+ return out.dev === err.dev && out.ino === err.ino;
10008
+ } catch {
10009
+ return false;
10010
+ }
10011
+ }
10012
+
9395
10013
  async function clearInFlightSpend(path) {
9396
10014
  if (path === null || path === undefined) {
9397
10015
  return;
@@ -9479,6 +10097,31 @@ async function apiRequest(input) {
9479
10097
  docs_url: "https://luxin.sh/cli.md",
9480
10098
  retry_after_seconds: 30,
9481
10099
  };
10100
+ // #2393: name the likely cause. ENOTFOUND (the API name does not
10101
+ // resolve) is the no-network sandbox and is not retryable; temporary DNS,
10102
+ // refused, reset, and timeout stay retryable with the idempotent retry
10103
+ // first and the sandbox hint as a possible cause.
10104
+ const classification = classifyNetworkError(error);
10105
+ if (classification !== null) {
10106
+ const body = networkErrorBody(
10107
+ input.apiBaseUrl,
10108
+ classification,
10109
+ typeof recovery.idempotency_key === "string"
10110
+ ? {
10111
+ idempotencyKey: recovery.idempotency_key,
10112
+ suggestedCommand: recovery.suggested_command,
10113
+ }
10114
+ : null,
10115
+ );
10116
+ return failure(
10117
+ input.command,
10118
+ 7,
10119
+ body.code,
10120
+ body.message,
10121
+ body.retryable,
10122
+ body.recovery,
10123
+ );
10124
+ }
9482
10125
  return failure(
9483
10126
  input.command,
9484
10127
  7,
@@ -9490,6 +10133,92 @@ async function apiRequest(input) {
9490
10133
  }
9491
10134
  }
9492
10135
 
10136
+ // Mirrors `src/network-unavailable.ts`; tests/network-unavailable.test.ts runs
10137
+ // one code table against both, so keep this function self-contained.
10138
+ function classifyNetworkError(error) {
10139
+ const unavailable = new Set(["ENOTFOUND"]);
10140
+ const transient = new Set([
10141
+ "EAI_AGAIN",
10142
+ "ECONNREFUSED",
10143
+ "ECONNRESET",
10144
+ "ETIMEDOUT",
10145
+ "ENETUNREACH",
10146
+ "EHOSTUNREACH",
10147
+ "UND_ERR_CONNECT_TIMEOUT",
10148
+ "UND_ERR_SOCKET",
10149
+ ]);
10150
+ let current = error;
10151
+ for (let depth = 0; depth < 4; depth += 1) {
10152
+ if (current === null || typeof current !== "object") {
10153
+ return null;
10154
+ }
10155
+ const code = current.code;
10156
+ if (typeof code === "string") {
10157
+ if (unavailable.has(code)) {
10158
+ return { kind: "unavailable", code };
10159
+ }
10160
+ if (transient.has(code)) {
10161
+ return { kind: "transient", code };
10162
+ }
10163
+ }
10164
+ current = current.cause;
10165
+ }
10166
+ return null;
10167
+ }
10168
+
10169
+ function networkErrorBody(apiBaseUrl, classification, retry) {
10170
+ let host = apiBaseUrl;
10171
+ try {
10172
+ host = new URL(apiBaseUrl).host;
10173
+ } catch {
10174
+ // keep the raw value
10175
+ }
10176
+ const hosts = [host, "media.luxin.sh"].filter(
10177
+ (value, index, all) => all.indexOf(value) === index,
10178
+ );
10179
+ const networkFixes = {
10180
+ codex: NETWORK_UNAVAILABLE_CODEX_FIX,
10181
+ generic: `allow outbound HTTPS (port 443) to ${hosts.join(" and ")}`,
10182
+ };
10183
+ const sandboxHint = `In a Codex sandbox network is off by default: rerun with \`${NETWORK_UNAVAILABLE_CODEX_FIX}\` or allow network for the session. In any other sandbox, allow outbound HTTPS to ${hosts.join(" and ")}.`;
10184
+ const code = classification.code;
10185
+ if (classification.kind === "unavailable") {
10186
+ return {
10187
+ code: "NETWORK_UNAVAILABLE",
10188
+ message: `${host} did not resolve from this shell (${code}); it most likely has no outbound network. Retrying will fail the same way until network is allowed.`,
10189
+ retryable: false,
10190
+ recovery: {
10191
+ reason: "network_unavailable",
10192
+ action: "enable_outbound_network",
10193
+ message: sandboxHint,
10194
+ hosts,
10195
+ network_fixes: networkFixes,
10196
+ docs_url: "https://luxin.sh/cli.md#network-unavailable",
10197
+ },
10198
+ };
10199
+ }
10200
+ return {
10201
+ code: "NETWORK_ERROR",
10202
+ message: `request to ${host} failed (${code}); this is usually transient.`,
10203
+ retryable: true,
10204
+ recovery: {
10205
+ reason: "network_error",
10206
+ action: "retry",
10207
+ message: `Retry in a few seconds${retry ? " with the same --idempotency-key, which dedupes to one charge" : ""}. If it persists, check sandbox network access: ${sandboxHint}`,
10208
+ hosts,
10209
+ network_fixes: networkFixes,
10210
+ docs_url: "https://luxin.sh/cli.md#network-unavailable",
10211
+ retry_after_seconds: 30,
10212
+ ...(retry
10213
+ ? {
10214
+ idempotency_key: retry.idempotencyKey,
10215
+ suggested_command: retry.suggestedCommand,
10216
+ }
10217
+ : {}),
10218
+ },
10219
+ };
10220
+ }
10221
+
9493
10222
  function parseEnvelope(text, command, statusCode, options = {}) {
9494
10223
  try {
9495
10224
  const parsed = JSON.parse(text);
@@ -9665,6 +10394,99 @@ function isRecord(value) {
9665
10394
  return value !== null && typeof value === "object" && !Array.isArray(value);
9666
10395
  }
9667
10396
 
10397
+ // `--prompt` or `--prompt-file`, never both; raw text, callers trim. Mirrors
10398
+ // `readPromptFlags` in `src/cli-local-output.ts`.
10399
+ async function readPromptFlags(args, { command, allowPositional }) {
10400
+ const hasPromptFile = args.flags.has("prompt-file");
10401
+ const promptFile = flagString(args, "prompt-file");
10402
+ if (hasPromptFile && (promptFile === null || promptFile.length === 0)) {
10403
+ return {
10404
+ ok: false,
10405
+ result: invalid(command, "--prompt-file requires a path"),
10406
+ };
10407
+ }
10408
+ const prompt = flagString(args, "prompt");
10409
+ if (prompt !== null && promptFile !== null) {
10410
+ return {
10411
+ ok: false,
10412
+ result: invalid(
10413
+ command,
10414
+ "provide either --prompt or --prompt-file, not both",
10415
+ ),
10416
+ };
10417
+ }
10418
+ if (promptFile !== null) {
10419
+ try {
10420
+ return {
10421
+ ok: true,
10422
+ prompt: await readFile(promptFile, "utf8"),
10423
+ promptFile,
10424
+ };
10425
+ } catch (error) {
10426
+ return {
10427
+ ok: false,
10428
+ result: invalid(
10429
+ command,
10430
+ `--prompt-file ${JSON.stringify(promptFile)} could not be read (${error?.code ?? "unreadable"})`,
10431
+ ),
10432
+ };
10433
+ }
10434
+ }
10435
+ return {
10436
+ ok: true,
10437
+ prompt: prompt ?? (allowPositional ? (args.positionals[0] ?? null) : null),
10438
+ promptFile: null,
10439
+ };
10440
+ }
10441
+
10442
+ // #2371: commands the guide emits replay `--prompt-file PATH` instead of
10443
+ // inlining the text. Rendered with the inline prompt first (one renderer for
10444
+ // both cases), then the exact rendered token is swapped. Mirrors
10445
+ // `replayPromptFileInCommands` in `src/cli-local-output.ts`.
10446
+ function replayPromptFileInCommands(value, { prompt, promptFile }) {
10447
+ if (promptFile === null || prompt.length === 0) {
10448
+ return value;
10449
+ }
10450
+ const inline = `--prompt ${shellQuote(prompt)}`;
10451
+ const replay = `--prompt-file ${shellQuote(promptFile)}`;
10452
+ const rewrite = (node) => {
10453
+ if (typeof node === "string") {
10454
+ return node.split(inline).join(replay);
10455
+ }
10456
+ if (Array.isArray(node)) {
10457
+ return node.map(rewrite);
10458
+ }
10459
+ if (node !== null && typeof node === "object") {
10460
+ return Object.fromEntries(
10461
+ Object.entries(node).map(([key, child]) => [key, rewrite(child)]),
10462
+ );
10463
+ }
10464
+ return node;
10465
+ };
10466
+ return rewrite(value);
10467
+ }
10468
+
10469
+ // A guide run with `--output PATH` emits a live create/edit `next_command`
10470
+ // that saves to the same PATH. Mirrors `replayOutputInGuideNextCommand`.
10471
+ function replayOutputInGuideNextCommand(data, outputPath, overwrite = false) {
10472
+ const command = data?.next_command;
10473
+ if (
10474
+ typeof outputPath !== "string" ||
10475
+ outputPath.trim().length === 0 ||
10476
+ typeof command !== "string" ||
10477
+ !/ (?:create|edit) /.test(command) ||
10478
+ / --(?:guide|dry-run)\b/.test(command) ||
10479
+ / --output\b/.test(command) ||
10480
+ !command.endsWith(" --json")
10481
+ ) {
10482
+ return data;
10483
+ }
10484
+ return {
10485
+ ...data,
10486
+ next_command: `${command.slice(0, -" --json".length)} --output ${shellQuote(outputPath.trim())}${overwrite ? " --overwrite" : ""} --json`,
10487
+ };
10488
+ }
10489
+
9668
10490
  async function promptValue(args) {
9669
10491
  const prompt = flagString(args, "prompt");
9670
10492
  const promptFile = flagString(args, "prompt-file");
@@ -10619,7 +11441,9 @@ async function downloadUrl(url, outputPath, options) {
10619
11441
  ),
10620
11442
  };
10621
11443
  }
10622
- const response = await fetch(url);
11444
+ const response = await fetch(url, {
11445
+ headers: { "user-agent": CLI_DOWNLOAD_USER_AGENT },
11446
+ });
10623
11447
  if (!response.ok || response.body === null) {
10624
11448
  return {
10625
11449
  ok: false,