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/CHANGELOG.md +67 -0
- package/README.md +35 -4
- package/bin/luxin.mjs +932 -108
- package/cli.md +118 -16
- package/commands.json +7 -1
- package/package.json +1 -1
- package/skill-core.md +11 -1
- package/skill.md +36 -39
- package/skills/agent-image-generation/SKILL.md +11 -13
- package/skills/ai-audio-generation/SKILL.md +11 -13
- package/skills/ai-image-generation/SKILL.md +11 -13
- package/skills/ai-video-generation/SKILL.md +11 -13
- package/skills/creative-media/SKILL.md +11 -13
- package/skills/image-edit/SKILL.md +11 -13
- package/skills/image-generation/SKILL.md +11 -13
- package/skills/image-skill/SKILL.md +11 -13
- package/skills/image-to-3d/SKILL.md +11 -13
- package/skills/luxin/SKILL.md +36 -39
- package/skills/luxin/references/cli.md +118 -16
- package/skills/luxin/references/commands.json +7 -1
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.
|
|
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 = "
|
|
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
|
-
|
|
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
|
|
384
|
-
{ re:
|
|
385
|
-
{ re: boundOrientationRe("
|
|
386
|
-
{ re: boundOrientationRe("
|
|
387
|
-
{ re: boundOrientationRe("
|
|
388
|
-
{ re: boundOrientationRe("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
:
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
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
|
|
3671
|
-
(model) => model?.modality === "video",
|
|
3672
|
-
|
|
3673
|
-
|
|
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
|
|
3749
|
-
|
|
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
|
|
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
|
-
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
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
|
|
6447
|
-
|
|
6448
|
-
|
|
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
|
|
7974
|
-
|
|
7975
|
-
|
|
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
|
-
|
|
9158
|
-
|
|
9159
|
-
|
|
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,
|