@genex-ai/cli-demo 1.36.13 → 1.36.14-dev.1028

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/dist/index.js CHANGED
@@ -57,7 +57,7 @@ import {
57
57
  writeSecretFile,
58
58
  writeUserToken,
59
59
  writeWorkspace
60
- } from "./chunk-4BBTPRJW.js";
60
+ } from "./chunk-EABIVAGT.js";
61
61
  import {
62
62
  CLI_CHANNEL,
63
63
  DEFAULT_API_URL,
@@ -78,7 +78,7 @@ import {
78
78
  normalizeApiOrigin,
79
79
  originKey,
80
80
  resolveAgentTargets
81
- } from "./chunk-QI7FIYBY.js";
81
+ } from "./chunk-K4EHXOZK.js";
82
82
 
83
83
  // src/instrument.ts
84
84
  import { setTimeout as sleep } from "timers/promises";
@@ -754,7 +754,7 @@ function tokenizeCommand(input) {
754
754
  // src/utils/logger.ts
755
755
  function createLogger(opts = {}) {
756
756
  const out = (s) => {
757
- if (!opts.quiet) writeSafely(process.stdout, s + "\n");
757
+ if (!opts.quiet) writeSafely(opts.toStderr ? process.stderr : process.stdout, s + "\n");
758
758
  };
759
759
  const err = (s) => {
760
760
  writeSafely(process.stderr, s + "\n");
@@ -1134,7 +1134,7 @@ Important note: put soul into your creations, with many details and love. Aim to
1134
1134
  21. A turn ends in exactly one of two ways: on a question the player must answer before the next step can be chosen, or on a handoff \u2014 the draft link, what changed, what to try, and what is still open. Never close a turn on a promise. "I'll keep building", "while I keep working", "adding that now" are things you say and then DO before you stop; if you are stopping, say that you have stopped and what remains. While the Build plan's \`Now:\` line has open work and no answer is needed from the player, the turn is not over: preview, hand off, and start the next milestone in the same turn, until the requested outcome is reached or the platform's own budget ends the session. If a stop was forced on you anyway, the first line of your next turn is the promise you left and where it stands.
1135
1135
  22. **What the player sees must read as the thing it is**, in this game's own style: a building as that building, a person as a person, a prop as that prop, a surface as its material \u2014 and a coloured box, a capsule, or a flat grey block standing in for one of them is never its finished version. Generation is the default route to that bar wherever code will not honestly reach it: the player's character, whatever the request names, whatever the player walks up to, enters, or interacts with, and the music and sound underneath \u2014 reach for the belt on your own judgment, without stopping to ask; a status line saying what you queued is enough, and \`--no-wait\` keeps you building while it lands. Procedural code is a first-class engine for what is structural, repeated, distant, or parametric \u2014 terrain, sky, fences, paving, modular kits, filler \u2014 and for anything else only when the result meets the same bar and you have looked at it in a capture before its row says \`landed\`. Start with one reusable implementation per distinct required gameplay or visual role. Background populations default to reusable procedural bodies and motion or compatible existing rigged assets. Catalog clips need a compatible skeleton and may be paid. Reserve paid custom bodies for the player and characters examined or interacted with closely, while honoring explicit user requests. Add variants only for an unmet requirement; unspent allowance is not unfinished work. Allowance questions use timeoutPolicy no-consent: silence does not raise the allowance. Record role, reuse and completion criteria in the existing Assets table. Decide per object, not per category, and write the route on each Assets row in \`DESIGN.md\` \u2014 the paid flow for generated pieces, the procedural flow with its local path for code-built ones. An Assets table with no generation in it is a decision, not a default: record it as \`Generation: none \u2014 <why code alone reaches the bar here>\` or generate.
1136
1136
  23. **The screenshots you take to look at your own work go in \`.genex/scratch/\`.** Captures, render comparisons, before/after strips, traces and metrics dumps are how you SEE what you built \u2014 they are not part of what you built, and \`genex preview\` pushes the whole folder to the game's source, every time, forever. \`.genex/scratch/\` already exists for this and is already ignored, so there is nothing to set up: write the capture as \`.genex/scratch/arena-before.png\` and read it back from there. Never put them in a folder of your own at the top level: \`progress/\`, \`reports/\`, \`shots/\` and their kind are pushed like source and become permanent weight in the game and in every remix of it \u2014 one real game reached 11 GB and 10,332 committed screenshots exactly this way. If you find such a folder already there, add it to \`.gitignore\`; never delete the player's files (law 18). One file in \`.genex/scratch/\` IS read, by the platform: \`.genex/scratch/cover.png\` is the game's cover \u2014 the frame you would put on its poster: real gameplay at its signature moment, well lit, no menus, popups or busy HUD, landscape 16:9, and never any text (the page sets the title beside it). The next \`genex preview\` makes it the cover; save a better moment over it whenever one comes along, and if your browser tool can set the cover itself, use that instead. Before the first publish, and whenever the game's look changes, load \`$genex-cover\`: it chooses, stages, lights and judges that frame.
1137
- 24. **Benchmark before declaring an in-game LLM price.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it, and \`estimateCoins\` is the FIXED price of a started attempt: charged whether it succeeds, fails, is canceled, or stops at its own budget. Never pick that number by judgement or from a vendor's rate card. Run the real prompt on your own coins \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-coins <n> --user-approved\` \u2014 and declare the figure it recommends, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit is a price change. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate is computed from the game's own loop. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
1137
+ 24. **Benchmark before declaring an in-game LLM ceiling.** If the game calls a language model while the player plays \u2014 an NPC answering in its own words, a quest written for this save, a judge reading what the player typed \u2014 the PLAYER pays for it from their Genex credits, billed as used, and the game declares no price, only a per-call CEILING: \`maxCredits\` on \`generate()\`, \`perCallMaxCredits\` on a standing budget. That ceiling bounds what one call may cost AND funds how long its answer may be, so never pick it by judgement or from a vendor's rate card. Run the real prompt on your own credits \u2014 \`npx genex llm bench "<the prompt>" --schema <file> --samples 3 --max-credits <n> --user-approved\` \u2014 and declare the figures it prints, recorded in \`DESIGN.md\` with its model and date; re-benchmark whenever the prompt, the schema or the model changes, because a prompt edit changes the ceiling. Check the lane first with \`npx genex llm models\`: where it is off the routes 404, so build the feature behind a graceful unavailable state and say so in the handoff. A repeated call is a standing budget the player approves once (\`requestSpendGrant()\`, then \`generate({ grantId })\`), never a popup per turn, and its disclosed call rate and credits per period are computed from the game's own loop and the bench's measured average. The coin spellings (\`estimateCoins\`, \`perCallMaxCoins\`) are deprecated \u2014 write the credit names. Model output is data the game validates against its own expectation \u2014 never executed, and never authority over coin, credits, items, entitlements or rewards. Load \`$genex-llm-in-games\` before writing any of it.
1138
1138
  ${CONTRACT_END}
1139
1139
  `;
1140
1140
  var TOOLS_CONTRACT_HEAD = `# Genex Tools (always in effect in this folder)
@@ -1148,7 +1148,7 @@ var TOOLS_CONTRACT_OFFER = `5. **When the game is built and playable \u2014 neve
1148
1148
  var TOOLS_CONTRACT_HOSTED_LAWS = `5. **This game is hosted on Genex now, and publishing is glue \u2014 never a rebuild.** The game ships exactly as the user built it: \`initEmbed()\` first in the boot code (the \`genex-threejs-embed-auth\` card has the call; the renderer draws before any \`await waitForPlayer()\`), a static build into \`dist/\` with relative asset paths, then \`npx genex preview\`. Never restructure, reformat or "improve" the game to ship it, never scaffold a new app around it, and never start a design document or a build plan for it \u2014 the user's own process is theirs. Load the \`genex-tool-publish\` card before the first preview; it owns the vocabulary, the links and the limits.
1149
1149
  6. **Ship first, then report.** \`preview\` prints a preflight \u2014 phone memory, a missing volume slider, an asset nobody wired, a viewport line. It never blocks a deploy, so push the build as it is, hand over the link, THEN relay each preflight line to the user in one plain sentence with an offer to fix it, and fix one only on their yes. The preflight is a report for the user, not a to-do list for you.
1150
1150
  7. **The link is the game's page, and there are two versions.** After every preview give the user \`<dashboard>/draft/<slug>\` \u2014 \`dashboardOrigins[0]\` and \`slug\` from \`.genex/project.json\` \u2014 never localhost, a file path or the bare play origin. \`preview\` updates the draft and never touches what players are on; the first release is \`npx genex publish\` (it lists the game); after that "publish it", "update it" and "yes" all mean \`npx genex promote\` \u2014 the exact draft build, no rebuild. Ask once per round of work, in one line, and keep working while you wait.`;
1151
- var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with Genex coins or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
1151
+ var TOOLS_CONTRACT_LLM_OFFER = `A model running while people play is built into the Genex platform \u2014 the player pays, with their Genex credits or their own Claude/ChatGPT subscription, and approves it on a Genex sheet; your game just calls \`generate()\`. Want it that way?`;
1152
1152
  function toolsContractLlmLaw(n, hosted) {
1153
1153
  const onYes = hosted ? "On a yes, load `$genex-tool-llm` and then `$genex-llm-in-games`, which owns the build." : "On a yes, load `$genex-tool-llm`, which owns the rest: it checks the lane with `npx genex llm models` first, then runs `npx genex init --convert` (a hosted game is a static browser build \u2014 a local server holding a key can never ship).";
1154
1154
  return `${n}. **A model running while people PLAY is a platform feature \u2014 never something you wire onto the user's own meter.** When a request implies one (NPCs that talk or decide in their own words, content written from what the player types, a prompt box in the game, "let the player pick a model"), recognise it, say in ONE line: "${TOOLS_CONTRACT_LLM_OFFER}" \u2014 then ASK and wait. ${onYes} On a no, build the authored version instead. Either way, never ship a game that calls a model on a key or an account of the user's own: every visitor would spend their money with nobody approving it, and the credential is readable in the bundle. Player-funded generation is text and JSON only \u2014 3D, images, video and audio stay the asset lanes above, on the user's meter.`;
@@ -1396,6 +1396,7 @@ var REFUSED_IN_TOOLS = /* @__PURE__ */ new Set([
1396
1396
  "cover",
1397
1397
  "make-remixable",
1398
1398
  "shop",
1399
+ "items",
1399
1400
  "domain",
1400
1401
  "asset",
1401
1402
  "controller"
@@ -2104,13 +2105,13 @@ async function extractSourceArchive(file, dest, rootFolder) {
2104
2105
  cursor += 46 + nameLen + extraLen + commentLen;
2105
2106
  }
2106
2107
  if (cursor !== central.length) badArchive("Source archive directory length mismatch.");
2107
- const byName = new Map(entries.map((e) => [e.name.normalize("NFC").toLowerCase(), e]));
2108
+ const byName2 = new Map(entries.map((e) => [e.name.normalize("NFC").toLowerCase(), e]));
2108
2109
  const sorted = [...entries].sort((a, b) => a.offset - b.offset);
2109
2110
  for (let i = 0; i < sorted.length; i++) {
2110
2111
  if (i && sorted[i].offset < sorted[i - 1].endOffset) badArchive("Source archive entries overlap.");
2111
2112
  const parts = sorted[i].name.split("/");
2112
2113
  for (let j = 1; j < parts.length; j++) {
2113
- const parent = byName.get(parts.slice(0, j).join("/").normalize("NFC").toLowerCase());
2114
+ const parent = byName2.get(parts.slice(0, j).join("/").normalize("NFC").toLowerCase());
2114
2115
  if (parent && !parent.dir) badArchive("Source archive file/link is used as a directory.");
2115
2116
  }
2116
2117
  }
@@ -3871,7 +3872,7 @@ import path17 from "path";
3871
3872
  import fs14 from "fs/promises";
3872
3873
  import path15 from "path";
3873
3874
  var FALLBACK_SOURCE_LIMITS = {
3874
- maxTotalBytes: 2 * 1024 * 1024 * 1024,
3875
+ maxTotalBytes: 5 * 1024 * 1024 * 1024,
3875
3876
  maxFiles: 5e3
3876
3877
  };
3877
3878
  function isOversized(p, limits) {
@@ -7232,11 +7233,11 @@ async function childRun(cwd, command, args, timeoutMs = 3e5) {
7232
7233
  env.YARN_ENABLE_IMMUTABLE_INSTALLS = "false";
7233
7234
  const child = spawn4(command, args, { cwd, env, shell: process.platform === "win32" && ["npm", "pnpm", "yarn", "bun"].includes(command), stdio: ["ignore", "pipe", "pipe"] });
7234
7235
  let tail = "";
7235
- const collect = (chunk) => {
7236
+ const collect2 = (chunk) => {
7236
7237
  tail = (tail + chunk.toString()).slice(-8e3);
7237
7238
  };
7238
- child.stdout.on("data", collect);
7239
- child.stderr.on("data", collect);
7239
+ child.stdout.on("data", collect2);
7240
+ child.stderr.on("data", collect2);
7240
7241
  const timer = setTimeout(() => {
7241
7242
  child.kill();
7242
7243
  reject(new RemixError("setup_timeout", `${command} timed out. The workspace is retained; retry the same remix command.`));
@@ -8553,30 +8554,14 @@ async function runDomain(opts) {
8553
8554
  log.success(`${hostname} disconnected.`);
8554
8555
  }
8555
8556
 
8556
- // src/commands/shop.ts
8557
- var SUBS = ["list", "add", "set", "remove", "test"];
8558
- function money(cents) {
8559
- return cents === void 0 ? "" : ` ($${(cents / 100).toFixed(2)})`;
8560
- }
8561
- async function runShop(opts) {
8562
- const log = createLogger({ quiet: opts.quiet });
8563
- const sub = opts.name?.trim() || "list";
8564
- if (!SUBS.includes(sub)) {
8565
- log.error(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS.join(", ")}.`);
8566
- process.exitCode = 1;
8567
- return;
8568
- }
8569
- if (opts.type !== void 0 && opts.type !== "consumable" && opts.type !== "durable") {
8570
- log.error(`Invalid --type value for shop: ${opts.type} (consumable or durable)`);
8571
- process.exitCode = 1;
8572
- return;
8573
- }
8557
+ // src/lib/linked-game.ts
8558
+ async function openLinkedGame(opts, log, rerun) {
8574
8559
  const meta = await readProject();
8575
8560
  if (!meta?.id) {
8576
8561
  log.error("This folder isn't linked to a game.");
8577
8562
  log.dim(` Run ${c.cyan("genex link")} (or ${c.cyan("genex list")} to find the slug) first.`);
8578
8563
  process.exitCode = 1;
8579
- return;
8564
+ return null;
8580
8565
  }
8581
8566
  const apiUrl = getApiUrl(opts.apiUrl ?? meta.apiUrl);
8582
8567
  let token = opts.token ?? await readUserToken(opts.envPath, apiUrl);
@@ -8584,7 +8569,7 @@ async function runShop(opts) {
8584
8569
  if (opts.noAuth) {
8585
8570
  log.error("Not signed in. Re-run without --no-auth to connect.");
8586
8571
  process.exitCode = 1;
8587
- return;
8572
+ return null;
8588
8573
  }
8589
8574
  log.plain("Not signed in \u2014 connecting\u2026");
8590
8575
  try {
@@ -8597,138 +8582,1389 @@ async function runShop(opts) {
8597
8582
  } catch (err) {
8598
8583
  if (err instanceof AuthPendingError) {
8599
8584
  printAuthHandoff(log, err);
8600
- log.dim(`Then re-run ${c.cyan(`genex shop ${sub}`)}.`);
8601
- return;
8585
+ log.dim(`Then re-run ${c.cyan(rerun)}.`);
8586
+ return null;
8602
8587
  }
8603
8588
  log.error(`Sign-in didn't complete: ${err instanceof Error ? err.message : String(err)}`);
8604
8589
  process.exitCode = 1;
8605
- return;
8590
+ return null;
8606
8591
  }
8607
8592
  await writeUserToken(token, opts.envPath, apiUrl);
8608
8593
  }
8609
8594
  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
8610
8595
  const call2 = (url, init) => apiFetch(url, { ...init, headers: { ...auth, ...init?.headers ?? {} } });
8611
- const projectSkus = `${apiUrl}/api/coin/projects/${encodeURIComponent(meta.id)}/skus`;
8612
- async function fail5(res2, what) {
8613
- if (printedStructuredError(res2)) {
8614
- process.exitCode = 1;
8615
- return;
8596
+ return { meta, apiUrl, call: call2 };
8597
+ }
8598
+ function formatCoins(coins) {
8599
+ return coins.toLocaleString("en-US");
8600
+ }
8601
+ function coinPrice(coins, displayUsdCents) {
8602
+ if (coins === 0) return "Free";
8603
+ if (displayUsdCents === void 0) return `${formatCoins(coins)} coin`;
8604
+ const usd = (displayUsdCents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
8605
+ return `${formatCoins(coins)} coin ($${usd})`;
8606
+ }
8607
+ function priceGridLine(grid, freeAllowed) {
8608
+ return `${[...freeAllowed ? ["Free"] : [], ...grid.map(formatCoins)].join(" \xB7 ")} coin`;
8609
+ }
8610
+
8611
+ // src/lib/item-refusal.ts
8612
+ var ITEM_ERROR_KIND = "genex.item_error";
8613
+ var ITEM_ERROR_VERSION = 1;
8614
+ var ItemErrorCode = {
8615
+ /** A command line the command cannot run (a missing name, a flag in the wrong place). */
8616
+ Usage: "usage",
8617
+ /** A name, description or tag list over its limit, judged here or by the API's validator. */
8618
+ InvalidField: "invalid_field",
8619
+ /** The API refused with no code the CLI can read. */
8620
+ HttpError: "http_error",
8621
+ /** The game folder refused to be written through (a link or a file in the way). */
8622
+ FolderRefused: "folder_refused"
8623
+ };
8624
+ var ITEM_NAME_MAX = 80;
8625
+ var ITEM_DESCRIPTION_MAX = 1e3;
8626
+ var ITEM_TAGS_MAX = 10;
8627
+ var ITEM_TAG_LENGTH_MAX = 32;
8628
+ var FIELD_OF_KEY = {
8629
+ name: "name",
8630
+ description: "description",
8631
+ tags: "tags",
8632
+ priceCoins: "price",
8633
+ iconUrl: "picture",
8634
+ type: "type",
8635
+ shareable: "shared",
8636
+ resellable: "resellable"
8637
+ };
8638
+ var LABEL_OF_KEY = {
8639
+ priceCoins: "price",
8640
+ iconUrl: "picture URL (--icon)"
8641
+ };
8642
+ var formatCount = (n) => n.toLocaleString("en-US");
8643
+ function zodIssuesOf(body) {
8644
+ if (typeof body !== "object" || body === null) return null;
8645
+ const error = body.error;
8646
+ if (typeof error !== "object" || error === null) return null;
8647
+ const issues = error.issues;
8648
+ if (!Array.isArray(issues) || issues.length === 0) return null;
8649
+ return issues.filter((i) => typeof i === "object" && i !== null);
8650
+ }
8651
+ function issueSentence(key, index, issue) {
8652
+ const measured = issue.type ?? issue.origin;
8653
+ const limit = typeof issue.maximum === "number" ? formatCount(issue.maximum) : null;
8654
+ const label = LABEL_OF_KEY[key] ?? key;
8655
+ if (issue.code === "too_big" && limit !== null) {
8656
+ if (key === "tags" && typeof index === "number") return `Each tag is at most ${limit} characters (tag ${index + 1} is longer).`;
8657
+ if (measured === "array") return key === "tags" ? `An item has at most ${limit} tags.` : `The ${label} has at most ${limit} entries.`;
8658
+ if (measured === "string") return `The ${label} is too long: at most ${limit} characters.`;
8659
+ return `The ${label} is at most ${limit}.`;
8660
+ }
8661
+ if (issue.code === "too_small" && issue.minimum === 1 && measured === "string") {
8662
+ return key === "tags" ? "A tag can't be empty." : `The ${label} can't be empty.`;
8663
+ }
8664
+ const detail = typeof issue.message === "string" ? `: ${issue.message}` : ".";
8665
+ return `The ${label} was refused${detail}`;
8666
+ }
8667
+ function refusalFromBody(status2, body, fallback) {
8668
+ const typed = body ?? {};
8669
+ if (typeof typed.error === "string") {
8670
+ return {
8671
+ error: typed.error,
8672
+ message: typeof typed.message === "string" && typed.message.trim() ? typed.message : `${fallback} (${typed.error}).`,
8673
+ ...typeof typed.field === "string" ? { field: typed.field } : {},
8674
+ ...typeof typed.path === "string" ? { path: typed.path } : {},
8675
+ ...typeof typed.retryAfterMs === "number" ? { retryAfterMs: typed.retryAfterMs } : {}
8676
+ };
8677
+ }
8678
+ const issues = zodIssuesOf(body);
8679
+ if (issues) {
8680
+ const sentences = [];
8681
+ let field;
8682
+ for (const issue of issues) {
8683
+ const [key, index] = Array.isArray(issue.path) ? issue.path : [];
8684
+ if (typeof key !== "string") continue;
8685
+ field ??= FIELD_OF_KEY[key] ?? key;
8686
+ const sentence = issueSentence(key, index, issue);
8687
+ if (!sentences.includes(sentence)) sentences.push(sentence);
8688
+ }
8689
+ if (sentences.length > 0) {
8690
+ return { error: ItemErrorCode.InvalidField, message: sentences.join(" "), ...field ? { field } : {} };
8691
+ }
8692
+ }
8693
+ return { error: ItemErrorCode.HttpError, message: `${fallback} (HTTP ${status2}).` };
8694
+ }
8695
+ async function bodyOf(res) {
8696
+ return res.json().catch(() => null);
8697
+ }
8698
+ function storedTags(tags2) {
8699
+ const seen = /* @__PURE__ */ new Set();
8700
+ for (const tag of tags2) {
8701
+ const stored = tag.trim().replace(/\s+/g, " ").toLowerCase();
8702
+ if (stored) seen.add(stored);
8703
+ }
8704
+ return [...seen];
8705
+ }
8706
+ function checkItemWords(words2) {
8707
+ const invalid2 = (field, message) => ({ error: ItemErrorCode.InvalidField, field, message });
8708
+ const name = words2.name?.trim();
8709
+ if (name !== void 0 && name.length > ITEM_NAME_MAX) {
8710
+ return invalid2("name", `The name is too long: at most ${formatCount(ITEM_NAME_MAX)} characters (it has ${formatCount(name.length)}).`);
8711
+ }
8712
+ const description = words2.description?.trim();
8713
+ if (description !== void 0 && description.length > ITEM_DESCRIPTION_MAX) {
8714
+ return invalid2(
8715
+ "description",
8716
+ `The description is too long: at most ${formatCount(ITEM_DESCRIPTION_MAX)} characters (it has ${formatCount(description.length)}).`
8717
+ );
8718
+ }
8719
+ if (!words2.tags) return null;
8720
+ const tags2 = storedTags(words2.tags);
8721
+ if (tags2.length > ITEM_TAGS_MAX) {
8722
+ return invalid2("tags", `An item has at most ${formatCount(ITEM_TAGS_MAX)} tags (this one has ${formatCount(tags2.length)}).`);
8723
+ }
8724
+ const long = tags2.find((tag) => tag.length > ITEM_TAG_LENGTH_MAX);
8725
+ if (long !== void 0) {
8726
+ return invalid2("tags", `Each tag is at most ${formatCount(ITEM_TAG_LENGTH_MAX)} characters ("${long.slice(0, 40)}${long.length > 40 ? "\u2026" : ""}" has ${formatCount(long.length)}).`);
8727
+ }
8728
+ return null;
8729
+ }
8730
+ var ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
8731
+ function itemErrorDocument(refusal2) {
8732
+ const plain = (text) => text.replace(ANSI, "").trim();
8733
+ const hint = refusal2.hints?.map(plain).filter(Boolean).join("\n");
8734
+ return {
8735
+ kind: ITEM_ERROR_KIND,
8736
+ version: ITEM_ERROR_VERSION,
8737
+ error: refusal2.error,
8738
+ ...refusal2.field !== void 0 ? { field: refusal2.field } : {},
8739
+ ...refusal2.path !== void 0 ? { path: refusal2.path } : {},
8740
+ message: plain(refusal2.message),
8741
+ ...refusal2.id !== void 0 ? { id: refusal2.id } : {},
8742
+ ...refusal2.retryAfterMs !== void 0 ? { retryAfterMs: refusal2.retryAfterMs } : {},
8743
+ ...hint ? { hint } : {}
8744
+ };
8745
+ }
8746
+ function reportItemRefusal(log, json, refusal2, printed = false) {
8747
+ process.exitCode = 1;
8748
+ if (!printed) {
8749
+ const code = refusal2.error === ItemErrorCode.Usage ? "" : ` ${c.dim(`[${refusal2.error}]`)}`;
8750
+ log.error(`${refusal2.message}${code}`);
8751
+ for (const line of refusal2.hints ?? []) writeSafely(process.stderr, `${c.dim(` ${line}`)}
8752
+ `);
8753
+ }
8754
+ if (json) writeSafely(process.stdout, `${JSON.stringify(itemErrorDocument(refusal2))}
8755
+ `);
8756
+ }
8757
+
8758
+ // src/lib/item-media.ts
8759
+ import fs24 from "fs/promises";
8760
+ var MIB = 1024 * 1024;
8761
+ var MediaKind = { Picture: "picture", Model: "model" };
8762
+ var MediaContentType = {
8763
+ Png: "image/png",
8764
+ Webp: "image/webp",
8765
+ Glb: "model/gltf-binary",
8766
+ Zip: "application/zip"
8767
+ };
8768
+ var MEDIA_BYTES_MAX = { picture: 2 * MIB, model: 20 * MIB };
8769
+ var MEDIA_FLAG = { picture: "--image", model: "--model" };
8770
+ var ItemMediaError = class extends Error {
8771
+ };
8772
+ var startsWith = (bytes, ascii, at = 0) => bytes.length >= at + ascii.length && bytes.subarray(at, at + ascii.length).toString("latin1") === ascii;
8773
+ function pictureType(bytes) {
8774
+ if (startsWith(bytes, "\x89PNG\r\n\n")) return MediaContentType.Png;
8775
+ if (startsWith(bytes, "RIFF") && startsWith(bytes, "WEBP", 8)) return MediaContentType.Webp;
8776
+ return null;
8777
+ }
8778
+ function modelType(bytes) {
8779
+ return startsWith(bytes, "glTF") ? MediaContentType.Glb : null;
8780
+ }
8781
+ async function readItemMedia(kind, file) {
8782
+ const flag = MEDIA_FLAG[kind];
8783
+ if (/^https?:\/\//i.test(file)) {
8784
+ throw new ItemMediaError(
8785
+ kind === MediaKind.Picture ? `${flag} takes a file on this machine. For a picture that is already online, pass --icon <url>.` : `${flag} takes a .glb file on this machine.`
8786
+ );
8787
+ }
8788
+ let size;
8789
+ try {
8790
+ const stat = await fs24.stat(file);
8791
+ if (!stat.isFile()) throw new Error("not a file");
8792
+ size = stat.size;
8793
+ } catch {
8794
+ throw new ItemMediaError(`${file} isn't a file (${flag}).`);
8795
+ }
8796
+ const max = MEDIA_BYTES_MAX[kind];
8797
+ if (size > max) {
8798
+ throw new ItemMediaError(`${file} is ${Math.ceil(size / MIB * 10) / 10} MB; ${kind === MediaKind.Picture ? "a picture" : "a model"} may be at most ${max / MIB} MB.`);
8799
+ }
8800
+ const bytes = await fs24.readFile(file);
8801
+ const contentType = kind === MediaKind.Picture ? pictureType(bytes) : modelType(bytes);
8802
+ if (!contentType) {
8803
+ throw new ItemMediaError(
8804
+ kind === MediaKind.Picture ? `${file} isn't a PNG or WebP picture. Render the item on a transparent background and save it as PNG.` : `${file} isn't a binary glTF model (.glb).`
8805
+ );
8806
+ }
8807
+ return { kind, file, bytes, contentType };
8808
+ }
8809
+
8810
+ // src/lib/item-package.ts
8811
+ import { createHash } from "crypto";
8812
+ import { constants as fsConstants } from "fs";
8813
+ import fs25 from "fs/promises";
8814
+ import path24 from "path";
8815
+ import zlib from "zlib";
8816
+ var MIB2 = 1024 * 1024;
8817
+ var ITEM_PACKAGE_BYTES_MAX = 50 * MIB2;
8818
+ var ITEM_PACKAGE_FILES_MAX = 2e3;
8819
+ var ITEM_PACKAGE_UNPACKED_BYTES_MAX = 200 * MIB2;
8820
+ var PackSkip = {
8821
+ /** Git metadata, `node_modules`, `.genex/`, and secrets (`.env`, keys, `.npmrc`). */
8822
+ Excluded: "excluded",
8823
+ /** Never followed, wherever it points. */
8824
+ Symlink: "symlink",
8825
+ /** A socket, FIFO or device. */
8826
+ NotAFile: "not_a_file",
8827
+ /** Bigger than a whole package may be. */
8828
+ TooLarge: "too_large",
8829
+ /** A name another system could not write back safely (a backslash, `:`, a reserved name). */
8830
+ UnsafeName: "unsafe_name",
8831
+ /** A top-level `item.json`: `genex items add` writes its own there ({@link ITEM_FILE_NAME}). */
8832
+ Reserved: "reserved",
8833
+ /** A `genex-items/` folder: other creators' items, for integration in this game only (plan 8.1). */
8834
+ SharedItem: "shared_item"
8835
+ };
8836
+ var PackageErrorCode = {
8837
+ DirMissing: "package_dir_missing",
8838
+ Empty: "package_empty",
8839
+ TooLarge: "package_too_large",
8840
+ TooManyFiles: "package_too_many_files",
8841
+ /** The archive is unsafe or malformed; `path` names the entry when there is one. */
8842
+ Invalid: "package_invalid",
8843
+ /** The archive is well formed but is not what the API recorded for it. */
8844
+ Mismatch: "package_mismatch",
8845
+ DestinationExists: "package_destination_exists",
8846
+ /** The signed link could not be read (network, expiry, an HTTP error). */
8847
+ DownloadFailed: "package_download_failed"
8848
+ };
8849
+ var ItemPackageError = class extends Error {
8850
+ code;
8851
+ /** The archive path at fault, when there is one. */
8852
+ entryPath;
8853
+ constructor(code, message, entryPath) {
8854
+ super(message);
8855
+ this.code = code;
8856
+ this.entryPath = entryPath;
8857
+ }
8858
+ };
8859
+ var ITEM_FILE_NAME = "item.json";
8860
+ var SHARED_ITEMS_DIR = "genex-items";
8861
+ function isReservedPath(relPath) {
8862
+ const top = relPath.split("/")[0] ?? "";
8863
+ return top.normalize("NFC").toLowerCase() === ITEM_FILE_NAME;
8864
+ }
8865
+ function packExclusion(relPath, name) {
8866
+ if (excludedSourcePath(relPath)) return PackSkip.Excluded;
8867
+ if (name.toLowerCase() === SHARED_ITEMS_DIR) return PackSkip.SharedItem;
8868
+ if (!relPath.includes("/") && isReservedPath(relPath)) return PackSkip.Reserved;
8869
+ return null;
8870
+ }
8871
+ async function packItemDirectory(dir) {
8872
+ const root = await realDirectory(dir);
8873
+ const collected = { files: [], skipped: [] };
8874
+ await collect(root, root, "", collected);
8875
+ if (collected.files.length === 0) {
8876
+ throw new ItemPackageError(PackageErrorCode.Empty, `${dir} has no files to package.`);
8877
+ }
8878
+ const zip = writeZip(collected.files);
8879
+ if (zip.length > ITEM_PACKAGE_BYTES_MAX) {
8880
+ throw new ItemPackageError(
8881
+ PackageErrorCode.TooLarge,
8882
+ `The package is ${megabytes(zip.length)} zipped; a package may be at most ${megabytes(ITEM_PACKAGE_BYTES_MAX)}.`
8883
+ );
8884
+ }
8885
+ return {
8886
+ zip,
8887
+ files: collected.files.map((f) => ({ path: f.path, bytes: f.data.length })),
8888
+ skipped: collected.skipped
8889
+ };
8890
+ }
8891
+ async function realDirectory(dir) {
8892
+ try {
8893
+ const root = await fs25.realpath(dir);
8894
+ if ((await fs25.stat(root)).isDirectory()) return root;
8895
+ } catch {
8896
+ }
8897
+ throw new ItemPackageError(PackageErrorCode.DirMissing, `${dir} is not a folder.`);
8898
+ }
8899
+ async function insideRoot(root, candidate) {
8900
+ try {
8901
+ const real = await fs25.realpath(candidate);
8902
+ return real === root || real.startsWith(root + path24.sep);
8903
+ } catch {
8904
+ return false;
8905
+ }
8906
+ }
8907
+ function portableName(relPath) {
8908
+ try {
8909
+ validateArchivePath(relPath);
8910
+ return true;
8911
+ } catch (err) {
8912
+ if (err instanceof RemixError) return false;
8913
+ throw err;
8914
+ }
8915
+ }
8916
+ var byName = (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
8917
+ async function collect(root, abs, rel, out) {
8918
+ const entries = (await fs25.readdir(abs, { withFileTypes: true })).sort(byName);
8919
+ for (const entry of entries) {
8920
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
8921
+ const full = path24.join(abs, entry.name);
8922
+ const excluded = packExclusion(relPath, entry.name);
8923
+ if (excluded) {
8924
+ out.skipped.push({ path: relPath, reason: excluded });
8925
+ continue;
8616
8926
  }
8617
- process.exitCode = 1;
8618
- const body = await res2.json().catch(() => ({}));
8619
- if (res2.status === 404 && body.error === "not_found") {
8620
- log.error("In-game purchases aren't enabled on this environment yet.");
8621
- return;
8927
+ if (entry.isSymbolicLink()) {
8928
+ out.skipped.push({ path: relPath, reason: PackSkip.Symlink });
8929
+ continue;
8930
+ }
8931
+ if (entry.isDirectory()) {
8932
+ if (await insideRoot(root, full)) await collect(root, full, relPath, out);
8933
+ else out.skipped.push({ path: relPath, reason: PackSkip.Symlink });
8934
+ continue;
8622
8935
  }
8623
- log.error(body.message ?? `Couldn't ${what} (HTTP ${res2.status}).`);
8624
- if (body.error === "price_off_grid") {
8625
- log.dim(` See the full list any time: ${c.cyan("genex shop list")}`);
8936
+ if (!entry.isFile()) {
8937
+ out.skipped.push({ path: relPath, reason: PackSkip.NotAFile });
8938
+ continue;
8626
8939
  }
8627
- if (body.error === "not_resellable_type") {
8628
- log.dim(` Add it with ${c.cyan("--type durable")} \u2014 a consumable is used up, so there is nothing left to resell.`);
8940
+ if (!portableName(relPath)) {
8941
+ out.skipped.push({ path: relPath, reason: PackSkip.UnsafeName });
8942
+ continue;
8943
+ }
8944
+ const read = await readRegularFile(root, full);
8945
+ if (!Buffer.isBuffer(read)) {
8946
+ out.skipped.push({ path: relPath, reason: read });
8947
+ continue;
8948
+ }
8949
+ out.files.push({ path: relPath, data: read });
8950
+ if (out.files.length > ITEM_PACKAGE_FILES_MAX) {
8951
+ throw new ItemPackageError(
8952
+ PackageErrorCode.TooManyFiles,
8953
+ `The folder holds more than ${ITEM_PACKAGE_FILES_MAX} files; a package may hold at most that many.`
8954
+ );
8629
8955
  }
8630
8956
  }
8631
- if (sub === "list") {
8632
- const res2 = await call2(projectSkus);
8633
- if (!res2.ok) return fail5(res2, "read your shop");
8634
- const body = await res2.json();
8635
- if (opts.json) {
8636
- log.plain(JSON.stringify(body, null, 2));
8637
- return;
8957
+ }
8958
+ async function readRegularFile(root, full) {
8959
+ if (!await insideRoot(root, path24.dirname(full))) return PackSkip.Symlink;
8960
+ let handle;
8961
+ try {
8962
+ handle = await fs25.open(full, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
8963
+ } catch {
8964
+ return PackSkip.Symlink;
8965
+ }
8966
+ try {
8967
+ const stat = await handle.stat();
8968
+ if (!stat.isFile()) return PackSkip.NotAFile;
8969
+ if (stat.size > ITEM_PACKAGE_BYTES_MAX) return PackSkip.TooLarge;
8970
+ const data = Buffer.alloc(stat.size);
8971
+ let filled = 0;
8972
+ while (filled < data.length) {
8973
+ const { bytesRead } = await handle.read(data, filled, data.length - filled, filled);
8974
+ if (bytesRead === 0) break;
8975
+ filled += bytesRead;
8976
+ }
8977
+ return data.subarray(0, filled);
8978
+ } finally {
8979
+ await handle.close();
8980
+ }
8981
+ }
8982
+ var LOCAL_SIGNATURE = 67324752;
8983
+ var CENTRAL_SIGNATURE = 33639248;
8984
+ var EOCD_SIGNATURE = 101010256;
8985
+ var DESCRIPTOR_SIGNATURE = 134695760;
8986
+ var LOCAL_HEADER_BYTES = 30;
8987
+ var CENTRAL_HEADER_BYTES = 46;
8988
+ var EOCD_BYTES = 22;
8989
+ var COMMENT_MAX = 65535;
8990
+ var METHOD_STORED = 0;
8991
+ var METHOD_DEFLATE = 8;
8992
+ var FLAG_ENCRYPTED = 1;
8993
+ var FLAG_DESCRIPTOR = 8;
8994
+ var FLAG_STRONG_ENCRYPTION = 64;
8995
+ var FLAG_UTF8 = 2048;
8996
+ var FLAG_MASKED_HEADER = 8192;
8997
+ var UNIX_HOST = 3;
8998
+ var MACOS_HOST = 19;
8999
+ var VERSION = 20;
9000
+ var DOS_DATE = 1 << 5 | 1;
9001
+ var S_IFMT = 61440;
9002
+ var S_IFREG = 32768;
9003
+ var S_IFDIR = 16384;
9004
+ var FILE_MODE = 420;
9005
+ function writeZip(files) {
9006
+ const parts = [];
9007
+ const central = [];
9008
+ let offset = 0;
9009
+ for (const file of files) {
9010
+ const name = Buffer.from(file.path, "utf8");
9011
+ const crc = sourceCrc32(file.data);
9012
+ const deflated = zlib.deflateRawSync(file.data, { level: 9 });
9013
+ const stored = deflated.length >= file.data.length;
9014
+ const body = stored ? file.data : deflated;
9015
+ const method = stored ? METHOD_STORED : METHOD_DEFLATE;
9016
+ const local = Buffer.alloc(LOCAL_HEADER_BYTES);
9017
+ local.writeUInt32LE(LOCAL_SIGNATURE, 0);
9018
+ local.writeUInt16LE(VERSION, 4);
9019
+ local.writeUInt16LE(FLAG_UTF8, 6);
9020
+ local.writeUInt16LE(method, 8);
9021
+ local.writeUInt16LE(0, 10);
9022
+ local.writeUInt16LE(DOS_DATE, 12);
9023
+ local.writeUInt32LE(crc, 14);
9024
+ local.writeUInt32LE(body.length, 18);
9025
+ local.writeUInt32LE(file.data.length, 22);
9026
+ local.writeUInt16LE(name.length, 26);
9027
+ local.writeUInt16LE(0, 28);
9028
+ const entry = Buffer.alloc(CENTRAL_HEADER_BYTES);
9029
+ entry.writeUInt32LE(CENTRAL_SIGNATURE, 0);
9030
+ entry.writeUInt16LE(UNIX_HOST << 8 | VERSION, 4);
9031
+ entry.writeUInt16LE(VERSION, 6);
9032
+ entry.writeUInt16LE(FLAG_UTF8, 8);
9033
+ entry.writeUInt16LE(method, 10);
9034
+ entry.writeUInt16LE(0, 12);
9035
+ entry.writeUInt16LE(DOS_DATE, 14);
9036
+ entry.writeUInt32LE(crc, 16);
9037
+ entry.writeUInt32LE(body.length, 20);
9038
+ entry.writeUInt32LE(file.data.length, 24);
9039
+ entry.writeUInt16LE(name.length, 28);
9040
+ entry.writeUInt32LE((S_IFREG | FILE_MODE) << 16 >>> 0, 38);
9041
+ entry.writeUInt32LE(offset, 42);
9042
+ parts.push(local, name, body);
9043
+ central.push(entry, name);
9044
+ offset += local.length + name.length + body.length;
9045
+ }
9046
+ const directory = Buffer.concat(central);
9047
+ const end = Buffer.alloc(EOCD_BYTES);
9048
+ end.writeUInt32LE(EOCD_SIGNATURE, 0);
9049
+ end.writeUInt16LE(files.length, 8);
9050
+ end.writeUInt16LE(files.length, 10);
9051
+ end.writeUInt32LE(directory.length, 12);
9052
+ end.writeUInt32LE(offset, 16);
9053
+ return Buffer.concat([...parts, directory, end]);
9054
+ }
9055
+ function invalid(message, entryPath) {
9056
+ throw new ItemPackageError(PackageErrorCode.Invalid, message, entryPath);
9057
+ }
9058
+ function findDirectory(zip) {
9059
+ for (let at = zip.length - EOCD_BYTES; at >= Math.max(0, zip.length - EOCD_BYTES - COMMENT_MAX); at--) {
9060
+ if (zip.readUInt32LE(at) !== EOCD_SIGNATURE) continue;
9061
+ if (at + EOCD_BYTES + zip.readUInt16LE(at + 20) !== zip.length) continue;
9062
+ const count = zip.readUInt16LE(at + 10);
9063
+ const bytes = zip.readUInt32LE(at + 12);
9064
+ const offset = zip.readUInt32LE(at + 16);
9065
+ const singleDisk = zip.readUInt16LE(at + 4) === 0 && zip.readUInt16LE(at + 6) === 0 && zip.readUInt16LE(at + 8) === count;
9066
+ if (!singleDisk) invalid("The package spans several disks.");
9067
+ if (count === 65535 || bytes === 4294967295 || offset === 4294967295) invalid("The package uses ZIP64, which a package never needs.");
9068
+ if (offset + bytes !== at) invalid("The package's directory is not where its end record says.");
9069
+ return { offset, bytes, count, end: at };
9070
+ }
9071
+ return invalid("The package is not a zip archive.");
9072
+ }
9073
+ function readEntries(zip) {
9074
+ if (zip.length < EOCD_BYTES) invalid("The package is not a zip archive.");
9075
+ if (zip.length > ITEM_PACKAGE_BYTES_MAX) {
9076
+ throw new ItemPackageError(PackageErrorCode.TooLarge, `The package is over ${megabytes(ITEM_PACKAGE_BYTES_MAX)}.`);
9077
+ }
9078
+ const directory = findDirectory(zip);
9079
+ if (directory.count > ITEM_PACKAGE_FILES_MAX * 2) invalid("The package holds too many entries.");
9080
+ const entries = [];
9081
+ const seen = /* @__PURE__ */ new Map();
9082
+ let cursor = directory.offset;
9083
+ let unpacked = 0;
9084
+ for (let i = 0; i < directory.count; i++) {
9085
+ const entry = readEntry(zip, cursor, directory.offset, directory.end);
9086
+ cursor = entry.next;
9087
+ const key = entry.value.path.normalize("NFC").toLowerCase();
9088
+ if (seen.has(key)) invalid(`The package holds ${entry.value.path} twice (names that differ only in case collide on disk).`, entry.value.path);
9089
+ seen.set(key, entry.value.dir);
9090
+ unpacked += entry.value.size;
9091
+ if (unpacked > ITEM_PACKAGE_UNPACKED_BYTES_MAX) invalid(`The package unpacks to more than ${megabytes(ITEM_PACKAGE_UNPACKED_BYTES_MAX)}.`);
9092
+ entries.push(entry.value);
9093
+ }
9094
+ if (cursor !== directory.end) invalid("The package's directory has trailing bytes.");
9095
+ if (entries.filter((e) => !e.dir).length > ITEM_PACKAGE_FILES_MAX) invalid(`The package holds more than ${ITEM_PACKAGE_FILES_MAX} files.`);
9096
+ for (const entry of entries) {
9097
+ const parts = entry.path.normalize("NFC").toLowerCase().split("/");
9098
+ for (let depth = 1; depth < parts.length; depth++) {
9099
+ if (seen.get(parts.slice(0, depth).join("/")) === false) invalid(`The package uses the file ${parts.slice(0, depth).join("/")} as a folder.`, entry.path);
9100
+ }
9101
+ }
9102
+ const byOffset = [...entries].sort((a, b) => a.dataOffset - b.dataOffset);
9103
+ for (let i = 1; i < byOffset.length; i++) {
9104
+ const previous = byOffset[i - 1];
9105
+ const current = byOffset[i];
9106
+ if (previous && current && current.dataOffset < previous.dataEnd) invalid("The package's entries overlap.", current.path);
9107
+ }
9108
+ return entries;
9109
+ }
9110
+ function readEntry(zip, at, directoryStart, directoryEnd) {
9111
+ if (at + CENTRAL_HEADER_BYTES > directoryEnd || zip.readUInt32LE(at) !== CENTRAL_SIGNATURE) invalid("The package's directory is damaged.");
9112
+ const madeBy = zip.readUInt16LE(at + 4) >>> 8;
9113
+ const flags = zip.readUInt16LE(at + 8);
9114
+ const method = zip.readUInt16LE(at + 10);
9115
+ const crc = zip.readUInt32LE(at + 16);
9116
+ const compressedSize = zip.readUInt32LE(at + 20);
9117
+ const size = zip.readUInt32LE(at + 24);
9118
+ const nameLength = zip.readUInt16LE(at + 28);
9119
+ const extraLength = zip.readUInt16LE(at + 30);
9120
+ const commentLength = zip.readUInt16LE(at + 32);
9121
+ const external = zip.readUInt32LE(at + 38);
9122
+ const localOffset = zip.readUInt32LE(at + 42);
9123
+ const next = at + CENTRAL_HEADER_BYTES + nameLength + extraLength + commentLength;
9124
+ if (next > directoryEnd) invalid("The package's directory is damaged.");
9125
+ const rawName = zip.subarray(at + CENTRAL_HEADER_BYTES, at + CENTRAL_HEADER_BYTES + nameLength).toString("utf8");
9126
+ let name;
9127
+ try {
9128
+ name = validateArchivePath(rawName);
9129
+ } catch (err) {
9130
+ if (err instanceof RemixError) invalid(`The package holds an unsafe path: ${JSON.stringify(rawName)}.`, rawName);
9131
+ throw err;
9132
+ }
9133
+ if (isReservedPath(name)) invalid(`The package holds its own ${ITEM_FILE_NAME}, which genex writes itself.`, name);
9134
+ if (flags & (FLAG_ENCRYPTED | FLAG_STRONG_ENCRYPTION | FLAG_MASKED_HEADER)) invalid(`${name} is encrypted.`, name);
9135
+ if (method !== METHOD_STORED && method !== METHOD_DEFLATE) invalid(`${name} uses a compression a package never needs.`, name);
9136
+ const mode = madeBy === UNIX_HOST || madeBy === MACOS_HOST ? external >>> 16 & S_IFMT : 0;
9137
+ const dir = rawName.endsWith("/") || mode === S_IFDIR;
9138
+ if (mode !== 0 && mode !== S_IFREG && mode !== S_IFDIR) invalid(`${name} is a link or a special file, not a plain file.`, name);
9139
+ if (dir && size !== 0) invalid(`The folder ${name} carries data.`, name);
9140
+ if (size > ITEM_PACKAGE_BYTES_MAX) invalid(`${name} is too large.`, name);
9141
+ if (method === METHOD_STORED && compressedSize !== size) invalid(`${name} has inconsistent sizes.`, name);
9142
+ if (localOffset + LOCAL_HEADER_BYTES > directoryStart || zip.readUInt32LE(localOffset) !== LOCAL_SIGNATURE) invalid(`${name} has no local header.`, name);
9143
+ const localNameLength = zip.readUInt16LE(localOffset + 26);
9144
+ const localExtraLength = zip.readUInt16LE(localOffset + 28);
9145
+ const localName = zip.subarray(localOffset + LOCAL_HEADER_BYTES, localOffset + LOCAL_HEADER_BYTES + localNameLength).toString("utf8");
9146
+ if (localName !== rawName || zip.readUInt16LE(localOffset + 8) !== method) invalid(`${name}'s local header disagrees with the directory.`, name);
9147
+ const dataOffset = localOffset + LOCAL_HEADER_BYTES + localNameLength + localExtraLength;
9148
+ const dataEnd = dataOffset + compressedSize + (flags & FLAG_DESCRIPTOR ? 16 : 0);
9149
+ if (dataEnd > directoryStart) invalid(`${name} is truncated.`, name);
9150
+ if (flags & FLAG_DESCRIPTOR && zip.readUInt32LE(dataOffset + compressedSize) !== DESCRIPTOR_SIGNATURE) {
9151
+ invalid(`${name}'s data descriptor is damaged.`, name);
9152
+ }
9153
+ return { value: { path: name, dir, method, crc, compressedSize, size, dataOffset, dataEnd }, next };
9154
+ }
9155
+ function entryBytes(zip, entry) {
9156
+ const raw = zip.subarray(entry.dataOffset, entry.dataOffset + entry.compressedSize);
9157
+ let data;
9158
+ if (entry.method === METHOD_STORED) data = raw;
9159
+ else {
9160
+ try {
9161
+ data = zlib.inflateRawSync(raw, { maxOutputLength: entry.size + 1 });
9162
+ } catch {
9163
+ return invalid(`${entry.path} does not decompress to its declared size.`, entry.path);
9164
+ }
9165
+ }
9166
+ if (data.length !== entry.size) invalid(`${entry.path} does not decompress to its declared size.`, entry.path);
9167
+ if (sourceCrc32(data) !== entry.crc) invalid(`${entry.path} fails its checksum.`, entry.path);
9168
+ return data;
9169
+ }
9170
+ var sha2562 = (data) => createHash("sha256").update(data).digest("hex");
9171
+ function checkAgainstManifest(zip, files, manifest) {
9172
+ const expected = new Map(manifest.files.map((f) => [f.path, f]));
9173
+ if (expected.size !== manifest.files.length) throw new ItemPackageError(PackageErrorCode.Mismatch, "The package's manifest lists a file twice.");
9174
+ for (const file of files) {
9175
+ const want = expected.get(file.path);
9176
+ if (!want) throw new ItemPackageError(PackageErrorCode.Mismatch, `${file.path} is in the package but not in its manifest.`, file.path);
9177
+ if (want.bytes !== file.size || want.sha256.toLowerCase() !== sha2562(entryBytes(zip, file))) {
9178
+ throw new ItemPackageError(PackageErrorCode.Mismatch, `${file.path} is not the file the manifest recorded.`, file.path);
9179
+ }
9180
+ expected.delete(file.path);
9181
+ }
9182
+ const missing = [...expected.keys()];
9183
+ if (missing.length > 0) throw new ItemPackageError(PackageErrorCode.Mismatch, `The package is missing ${missing[0]}, which its manifest lists.`, missing[0]);
9184
+ }
9185
+ async function extractItemPackage(zip, dest, manifest) {
9186
+ const entries = readEntries(zip);
9187
+ const files = entries.filter((e) => !e.dir);
9188
+ for (const file of files) entryBytes(zip, file);
9189
+ if (manifest) checkAgainstManifest(zip, files, manifest);
9190
+ try {
9191
+ await fs25.mkdir(dest);
9192
+ } catch (err) {
9193
+ if (err.code === "EEXIST") {
9194
+ throw new ItemPackageError(PackageErrorCode.DestinationExists, `${dest} already exists.`);
9195
+ }
9196
+ throw err;
9197
+ }
9198
+ for (const entry of entries) {
9199
+ const target = path24.join(dest, ...entry.path.split("/"));
9200
+ if (entry.dir) {
9201
+ await fs25.mkdir(target, { recursive: true });
9202
+ continue;
8638
9203
  }
9204
+ await fs25.mkdir(path24.dirname(target), { recursive: true });
9205
+ await fs25.writeFile(target, entryBytes(zip, entry), { flag: "wx", mode: FILE_MODE });
9206
+ }
9207
+ return files.map((f) => f.path);
9208
+ }
9209
+ function megabytes(bytes) {
9210
+ return `${Math.round(bytes / MIB2 * 10) / 10} MB`;
9211
+ }
9212
+
9213
+ // src/lib/item-transfer.ts
9214
+ var DOWNLOAD_TIMEOUT_MS = 12e4;
9215
+ var UPLOAD_TIMEOUT_MS = 3e5;
9216
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
9217
+ function isTransferUrl(url) {
9218
+ let parsed;
9219
+ try {
9220
+ parsed = new URL(url);
9221
+ } catch {
9222
+ return false;
9223
+ }
9224
+ if (parsed.username || parsed.password) return false;
9225
+ if (parsed.protocol === "https:") return true;
9226
+ return parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
9227
+ }
9228
+ async function putSigned(uploadUrl, bytes, contentType) {
9229
+ if (!isTransferUrl(uploadUrl)) throw new Error("The upload link Genex returned isn't an https URL.");
9230
+ const res = await fetch(uploadUrl, {
9231
+ method: "PUT",
9232
+ headers: { "Content-Type": contentType },
9233
+ body: bytes,
9234
+ redirect: "error",
9235
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
9236
+ });
9237
+ if (!res.ok) throw new Error(`The upload was refused (HTTP ${res.status}).`);
9238
+ }
9239
+ async function downloadItemPackage(url, fetchImpl = fetch) {
9240
+ if (!isTransferUrl(url)) {
9241
+ throw new ItemPackageError(PackageErrorCode.Invalid, "The package link Genex returned isn't an https URL.");
9242
+ }
9243
+ let res;
9244
+ try {
9245
+ res = await fetchImpl(url, { redirect: "error", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
9246
+ } catch (err) {
9247
+ throw new ItemPackageError(PackageErrorCode.DownloadFailed, `The package download failed: ${err instanceof Error ? err.message : String(err)}.`);
9248
+ }
9249
+ if (!res.ok || !res.body) {
9250
+ throw new ItemPackageError(PackageErrorCode.DownloadFailed, `The package download failed (HTTP ${res.status}).`);
9251
+ }
9252
+ const declared = Number(res.headers.get("content-length") ?? "0");
9253
+ if (declared > ITEM_PACKAGE_BYTES_MAX) {
9254
+ await res.body.cancel().catch(() => {
9255
+ });
9256
+ throw new ItemPackageError(PackageErrorCode.TooLarge, "The package is over the 50 MB limit.");
9257
+ }
9258
+ const chunks = [];
9259
+ let total = 0;
9260
+ for await (const chunk of res.body) {
9261
+ total += chunk.length;
9262
+ if (total > ITEM_PACKAGE_BYTES_MAX) {
9263
+ throw new ItemPackageError(PackageErrorCode.TooLarge, "The package is over the 50 MB limit.");
9264
+ }
9265
+ chunks.push(chunk);
9266
+ }
9267
+ return Buffer.concat(chunks);
9268
+ }
9269
+
9270
+ // src/commands/shop.ts
9271
+ var ITEM_DRAFT_KIND = "genex.item_draft";
9272
+ var ITEM_DRAFT_VERSION = 1;
9273
+ var SUBS = ["list", "add", "set", "remove", "test", "publish", "unpublish"];
9274
+ var STATE_LABEL = {
9275
+ draft: "draft \u2014 not on sale yet",
9276
+ off_sale: "off sale",
9277
+ taken_down: "taken off sale after a complaint"
9278
+ };
9279
+ var REFUSED_FILE_CODES = /* @__PURE__ */ new Set([
9280
+ "picture_invalid",
9281
+ "model_invalid",
9282
+ "media_type",
9283
+ "media_too_large",
9284
+ "package_invalid",
9285
+ "package_too_large"
9286
+ ]);
9287
+ var ANOTHER_FILE = { picture: "<picture.png>", model: "<file.glb>", package: "<dir>" };
9288
+ function usageRefusal(message) {
9289
+ return { error: ItemErrorCode.Usage, message };
9290
+ }
9291
+ function clearTagsRefusal(sub, opts) {
9292
+ if (!opts.clearTags) return null;
9293
+ if (sub !== "set") return usageRefusal(`--clear-tags empties a listed item's tags: ${c.cyan("genex shop set <id> --clear-tags")}.`);
9294
+ if (opts.itemTags?.length) return usageRefusal("Pass --tag (the new list) or --clear-tags (no tags), not both.");
9295
+ return null;
9296
+ }
9297
+ async function runShop(opts) {
9298
+ const log = createLogger({ quiet: opts.quiet, toStderr: opts.json });
9299
+ const refuse = (refusal2) => reportItemRefusal(log, opts.json, refusal2);
9300
+ const sub = opts.name?.trim() || "list";
9301
+ if (!SUBS.includes(sub)) return refuse(usageRefusal(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS.join(", ")}.`));
9302
+ if (opts.type !== void 0 && opts.type !== "consumable" && opts.type !== "durable") {
9303
+ return refuse({ ...usageRefusal(`Invalid --type value for shop: ${opts.type} (consumable or durable)`), field: "type" });
9304
+ }
9305
+ const clearTags = clearTagsRefusal(sub, opts);
9306
+ if (clearTags) return refuse(clearTags);
9307
+ let attachments = { media: [], pack: null };
9308
+ if (sub === "add" || sub === "set") {
9309
+ const words2 = checkItemWords({ name: sub === "add" ? opts.hostname : opts.rename, description: opts.description, tags: opts.itemTags });
9310
+ if (words2) return refuse(words2);
9311
+ const prepared = await prepareAttachments(opts, refuse);
9312
+ if (!prepared) return;
9313
+ attachments = prepared;
9314
+ }
9315
+ const game = await openLinkedGame(opts, log, `genex shop ${sub}`);
9316
+ if (!game) return;
9317
+ const shop = new ShopSession(game, log, opts);
9318
+ if (sub === "list") return shop.list();
9319
+ if (sub === "add") return shop.add(attachments);
9320
+ const skuId = opts.hostname?.trim();
9321
+ if (!skuId) return refuse(usageRefusal(`\`genex shop ${sub}\` needs an item id \u2014 see ${c.cyan("genex shop list")}.`));
9322
+ if (sub === "test") return shop.testGrant(skuId);
9323
+ if (sub === "set") return shop.set(skuId, attachments);
9324
+ if (sub === "publish") return shop.publish(skuId);
9325
+ if (sub === "unpublish") return shop.unpublish(skuId);
9326
+ return shop.remove(skuId);
9327
+ }
9328
+ async function judgedMedia(kind, file) {
9329
+ try {
9330
+ return await readItemMedia(kind, file);
9331
+ } catch (err) {
9332
+ if (!(err instanceof ItemMediaError)) throw err;
9333
+ return { error: kind === MediaKind.Picture ? "picture_invalid" : "model_invalid", field: kind, message: err.message };
9334
+ }
9335
+ }
9336
+ async function prepareAttachments(opts, refuse) {
9337
+ const refused = (refusal2) => {
9338
+ refuse(refusal2);
9339
+ return null;
9340
+ };
9341
+ if (opts.shared === true && opts.type === "consumable") {
9342
+ return refused({
9343
+ error: "not_durable",
9344
+ field: "type",
9345
+ message: "Only a durable item can be shared \u2014 other games keep it, and a consumable is used up. Use --type durable, or drop --shared."
9346
+ });
9347
+ }
9348
+ if (opts.icon && opts.picture) {
9349
+ return refused(usageRefusal("Pass --icon or --image, not both: --icon takes a picture URL, --image a picture file on this machine."));
9350
+ }
9351
+ const media = [];
9352
+ for (const [kind, file] of [[MediaKind.Picture, opts.picture], [MediaKind.Model, opts.modelFile]]) {
9353
+ if (!file) continue;
9354
+ const judged = await judgedMedia(kind, file);
9355
+ if ("error" in judged) return refused(judged);
9356
+ media.push(judged);
9357
+ }
9358
+ let pack2 = null;
9359
+ if (opts.packageDir) {
9360
+ try {
9361
+ pack2 = await packItemDirectory(opts.packageDir);
9362
+ } catch (err) {
9363
+ if (err instanceof ItemPackageError) return refused({ error: "package_invalid", field: "package", message: `${err.message} (--package)` });
9364
+ throw err;
9365
+ }
9366
+ }
9367
+ return { media, pack: pack2 };
9368
+ }
9369
+ var SKIP_REASON = {
9370
+ excluded: "machine state or a secret",
9371
+ symlink: "a link \u2014 links are never followed",
9372
+ not_a_file: "not a regular file",
9373
+ too_large: "too large for any package",
9374
+ unsafe_name: "a name other systems cannot write back",
9375
+ reserved: "genex items add writes its own item.json there",
9376
+ shared_item: "other creators' items are for this game only"
9377
+ };
9378
+ var ShopSession = class {
9379
+ game;
9380
+ log;
9381
+ opts;
9382
+ projectSkus;
9383
+ constructor(game, log, opts) {
9384
+ this.game = game;
9385
+ this.log = log;
9386
+ this.opts = opts;
9387
+ this.projectSkus = `${game.apiUrl}/api/coin/projects/${encodeURIComponent(game.meta.id)}/skus`;
9388
+ }
9389
+ skuUrl(skuId) {
9390
+ return `${this.game.apiUrl}/api/coin/skus/${encodeURIComponent(skuId)}`;
9391
+ }
9392
+ post(url, body) {
9393
+ return this.game.call(url, { method: "POST", ...body === void 0 ? {} : { body: JSON.stringify(body) } });
9394
+ }
9395
+ /** stdout, whole: the one document a `--json` run prints. */
9396
+ printJson(value, pretty = true) {
9397
+ writeSafely(process.stdout, `${pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)}
9398
+ `);
9399
+ }
9400
+ /**
9401
+ * The draft document: the item flattened with `kind` and `version`. Only an
9402
+ * API that speaks the store contract (it sends `state`) gets the stamp; an
9403
+ * older one's bare item prints as it always did, so the app can never
9404
+ * mistake it for a draft.
9405
+ */
9406
+ printDraft(sku) {
9407
+ if (typeof sku.state === "string") this.printJson({ kind: ITEM_DRAFT_KIND, version: ITEM_DRAFT_VERSION, ...sku }, false);
9408
+ else this.printJson(sku);
9409
+ }
9410
+ /** Print a refusal this command decided itself (exit code 1). */
9411
+ refuse(refusal2) {
9412
+ reportItemRefusal(this.log, this.opts.json, refusal2);
9413
+ }
9414
+ /** The server's price grid, or null when it cannot be read. */
9415
+ async priceGrid() {
9416
+ const res = await this.game.call(this.projectSkus).catch(() => null);
9417
+ if (!res?.ok) return null;
9418
+ const body = await res.json().catch(() => null);
9419
+ const grid = body?.priceGrid;
9420
+ return Array.isArray(grid) && grid.every((n) => typeof n === "number") ? grid : null;
9421
+ }
9422
+ /**
9423
+ * Does this server take a price of 0 (Free)? Only while its item store is
9424
+ * on (contract D7); the store routes answer then and 404 otherwise, the
9425
+ * probe the MCP connector's `item_draft` makes too. Unknown reads as no, so
9426
+ * Free is never offered where it would be refused.
9427
+ */
9428
+ async freeAllowed() {
9429
+ const res = await this.game.call(`${this.game.apiUrl}/api/store/catalog?limit=1`).catch(() => null);
9430
+ return res?.ok === true;
9431
+ }
9432
+ /** The grid line every price hint prints, or null when the grid cannot be read. */
9433
+ async gridLine() {
9434
+ const [grid, free] = await Promise.all([this.priceGrid(), this.freeAllowed()]);
9435
+ return grid ? priceGridLine(grid, free) : null;
9436
+ }
9437
+ /** Report a refused API answer (exit code 1): the person's line and hints, and the `--json` document. */
9438
+ async fail(res, what, skuId) {
9439
+ const printed = printedStructuredError(res);
9440
+ reportItemRefusal(this.log, this.opts.json, await this.refusalFor(res, what, skuId, printed), printed);
9441
+ }
9442
+ /** One place to turn an API refusal into something actionable. */
9443
+ async refusalFor(res, what, skuId, printed) {
9444
+ const refusal2 = refusalFromBody(res.status, await bodyOf(res), `Couldn't ${what}`);
9445
+ if (res.status === 404 && refusal2.error === "not_found") {
9446
+ return { ...refusal2, message: "In-game purchases aren't enabled on this environment yet." };
9447
+ }
9448
+ if (skuId && refusal2.error === "project_not_found") return { ...refusal2, message: "That item is not yours (or does not exist)." };
9449
+ if (printed) return refusal2;
9450
+ return this.withHints(refusal2, skuId ?? "<id>");
9451
+ }
9452
+ /** The next step for each refusal the creator can act on. */
9453
+ async withHints(refusal2, id) {
9454
+ const hint = (...hints) => ({ ...refusal2, hints });
9455
+ const media = refusal2.field === "model" ? { flag: "--model", file: ANOTHER_FILE.model } : { flag: "--image", file: ANOTHER_FILE.picture };
9456
+ switch (refusal2.error) {
9457
+ case "price_off_grid":
9458
+ case "bad_price": {
9459
+ const grid = await this.gridLine();
9460
+ const price = this.opts.price;
9461
+ const named = grid && refusal2.error === "price_off_grid" && price !== void 0;
9462
+ return {
9463
+ ...refusal2,
9464
+ ...named ? { message: `${formatCoins(price)} coin isn't one of this game's prices.` } : {},
9465
+ hints: [...grid ? [`Prices available: ${grid}`] : [], `See the full list any time: ${c.cyan("genex shop list")}`]
9466
+ };
9467
+ }
9468
+ case "not_resellable_type":
9469
+ return hint(`Add it with ${c.cyan("--type durable")} \u2014 a consumable is used up, so there is nothing left to resell.`);
9470
+ case "not_durable":
9471
+ return hint(`Only a durable item can be shared: ${c.cyan(`genex shop set ${id} --type durable`)} first.`);
9472
+ case "picture_missing":
9473
+ return hint(`Render the item on a transparent background, then: ${c.cyan(`genex shop set ${id} --image <picture.png>`)}`);
9474
+ case "picture_invalid":
9475
+ case "media_type":
9476
+ case "media_too_large":
9477
+ return hint(`A picture is a PNG or WebP up to 2 MB, a model a .glb up to 20 MB: ${c.cyan(`genex shop set ${id} ${media.flag} ${media.file}`)}`);
9478
+ case "model_invalid":
9479
+ return hint(`Replace it with another GLB: ${c.cyan(`genex shop set ${id} --model ${ANOTHER_FILE.model}`)}`);
9480
+ case "package_invalid":
9481
+ return hint(
9482
+ `The package holds ${refusal2.path ?? "a path"} that a package may not (a link, an absolute or a ../ path). Fix the folder, then ${c.cyan(`genex shop set ${id} --package <dir>`)}.`
9483
+ );
9484
+ case "package_too_large":
9485
+ return hint("A package may be at most 50 MB zipped.");
9486
+ case "name_refused":
9487
+ return hint(`Rename it: ${c.cyan(`genex shop set ${id} --rename "<name>"`)}`);
9488
+ case "description_refused":
9489
+ return hint(`Rewrite it: ${c.cyan(`genex shop set ${id} --description "<text>"`)}`);
9490
+ case "taken_down":
9491
+ return hint("An admin took this item off sale after a complaint. It can't go back on sale; its owners keep it.");
9492
+ case "publish_rate_limited":
9493
+ return hint(`Too many items put on sale today. Try again in ${Math.max(1, Math.ceil((refusal2.retryAfterMs ?? 0) / 6e4))} minutes.`);
9494
+ case "too_many_skus":
9495
+ return hint(`Retire an item this game no longer sells: ${c.cyan("genex shop remove <id>")}`);
9496
+ case "type_locked":
9497
+ return hint("Someone already owns it, so its type can no longer change.");
9498
+ default:
9499
+ return refusal2;
9500
+ }
9501
+ }
9502
+ async list() {
9503
+ const [res, free] = await Promise.all([this.game.call(this.projectSkus), this.opts.json ? false : this.freeAllowed()]);
9504
+ if (!res.ok) return this.fail(res, "read your shop");
9505
+ const body = await res.json();
9506
+ if (this.opts.json) return this.printJson(body);
9507
+ const { log } = this;
9508
+ const prices = `Prices available: ${priceGridLine(body.priceGrid, free)}`;
8639
9509
  if (body.items.length === 0) {
8640
9510
  log.plain("This game sells nothing yet.");
8641
9511
  log.dim(` Add something: ${c.cyan('genex shop add "Iron Key" --price 100')}`);
8642
- log.dim(` Prices available: ${body.priceGrid.join(", ")} coin`);
9512
+ log.dim(` ${prices}`);
8643
9513
  return;
8644
9514
  }
8645
9515
  log.plain(`${body.items.length} item${body.items.length === 1 ? "" : "s"}:`);
8646
9516
  for (const s of body.items) {
8647
- const state = s.active ? "" : c.dim(" (retired)");
9517
+ const label = s.retired ? "retired" : s.state === void 0 ? s.active ? null : "retired" : STATE_LABEL[s.state] ?? null;
9518
+ const state = label ? c.dim(` (${label})`) : "";
9519
+ const shared = s.shareable ? c.dim(" \xB7 other games can use it") : "";
8648
9520
  const resale = s.resellable ? c.dim(" \xB7 resellable") : "";
8649
- log.plain(
8650
- ` ${c.cyan(s.id)} ${s.name} \u2014 ${s.priceCoins} coin${money(s.priceDisplayUsdCents)} ${c.dim(s.type)}${resale}${state}`
8651
- );
9521
+ log.plain(` ${c.cyan(s.id)} ${s.name} \u2014 ${coinPrice(s.priceCoins, s.priceDisplayUsdCents)} ${c.dim(s.type)}${shared}${resale}${state}`);
8652
9522
  }
8653
- log.dim(`Prices available: ${body.priceGrid.join(", ")} coin`);
8654
- return;
9523
+ log.dim(prices);
8655
9524
  }
8656
- if (sub === "add") {
9525
+ async add(attachments) {
9526
+ const { log, opts } = this;
8657
9527
  const name = opts.hostname?.trim();
8658
- if (!name) {
8659
- log.error(`\`genex shop add\` needs a name, e.g. ${c.cyan('genex shop add "Iron Key" --price 100')}.`);
8660
- process.exitCode = 1;
8661
- return;
8662
- }
8663
- if (!opts.price) {
8664
- log.error(`\`genex shop add\` needs ${c.cyan("--price")}, in coin.`);
8665
- process.exitCode = 1;
8666
- return;
8667
- }
8668
- const res2 = await call2(projectSkus, {
8669
- method: "POST",
8670
- body: JSON.stringify({
8671
- name,
8672
- priceCoins: opts.price,
8673
- type: opts.type === "durable" ? "durable" : "consumable",
8674
- ...opts.icon ? { iconUrl: opts.icon } : {},
8675
- ...opts.resellable !== void 0 ? { resellable: opts.resellable } : {}
8676
- })
9528
+ if (!name) return this.refuse(usageRefusal(`\`genex shop add\` needs a name, e.g. ${c.cyan('genex shop add "Iron Key" --price 100')}.`));
9529
+ if (opts.price === void 0) return this.refuse(usageRefusal(`\`genex shop add\` needs ${c.cyan("--price")}, in coin (0 is Free).`));
9530
+ const type = opts.type ?? (opts.shared ? "durable" : "consumable");
9531
+ const res = await this.post(this.projectSkus, {
9532
+ name,
9533
+ priceCoins: opts.price,
9534
+ type,
9535
+ ...opts.description ? { description: opts.description } : {},
9536
+ ...opts.itemTags?.length ? { tags: opts.itemTags } : {},
9537
+ ...opts.icon ? { iconUrl: opts.icon } : {},
9538
+ ...opts.shared !== void 0 ? { shareable: opts.shared } : {},
9539
+ ...opts.resellable !== void 0 ? { resellable: opts.resellable } : {}
8677
9540
  });
8678
- if (!res2.ok) return fail5(res2, "add that item");
8679
- const sku = await res2.json();
8680
- if (opts.json) {
8681
- log.plain(JSON.stringify(sku, null, 2));
8682
- return;
8683
- }
8684
- log.success(`Added ${c.cyan(sku.name)} \u2014 ${sku.priceCoins} coin.`);
9541
+ if (!res.ok) return this.fail(res, "add that item");
9542
+ const created = await res.json();
9543
+ const attached = await this.attach(created.id, created, attachments);
9544
+ if (!attached) return;
9545
+ const sku = attached.sku ?? created;
9546
+ if (opts.json) return this.printDraft(sku);
9547
+ const draft = sku.state === "draft";
9548
+ log.success(`Added ${c.cyan(sku.name)} \u2014 ${coinPrice(sku.priceCoins, sku.priceDisplayUsdCents)}${draft ? ", as a draft" : ""}.`);
8685
9549
  log.plain(` id: ${c.cyan(sku.id)}`);
8686
9550
  log.dim(` Use it in the game: buy({ skuId: "${sku.id}" })`);
8687
9551
  if (sku.resellable) log.dim(" Players can resell this to each other on the market.");
8688
- return;
8689
- }
8690
- const skuId = opts.hostname?.trim();
8691
- if (!skuId) {
8692
- log.error(`\`genex shop ${sub}\` needs an item id \u2014 see ${c.cyan("genex shop list")}.`);
8693
- process.exitCode = 1;
8694
- return;
9552
+ if (draft) {
9553
+ log.dim(" It is not on sale yet: the person puts it on sale in the Genex app (Create \u203A Items \u203A Selling);");
9554
+ log.dim(` from a terminal: ${c.cyan(`genex shop publish ${sku.id}`)}`);
9555
+ }
8695
9556
  }
8696
- const skuUrl = `${apiUrl}/api/coin/skus/${encodeURIComponent(skuId)}`;
8697
- if (sub === "test") {
8698
- const res2 = await call2(`${skuUrl}/test-grant`, { method: "POST" });
8699
- if (!res2.ok) return fail5(res2, "grant yourself a test copy");
8700
- const got = await res2.json();
9557
+ async set(skuId, attachments) {
9558
+ const { log, opts } = this;
9559
+ const patch = {};
9560
+ if (opts.rename) patch.name = opts.rename;
9561
+ if (opts.price !== void 0) patch.priceCoins = opts.price;
9562
+ if (opts.type !== void 0) patch.type = opts.type;
9563
+ if (opts.description !== void 0) patch.description = opts.description.trim() === "" ? null : opts.description;
9564
+ if (opts.itemTags?.length) patch.tags = opts.itemTags;
9565
+ if (opts.clearTags) patch.tags = [];
9566
+ if (opts.icon) patch.iconUrl = opts.icon;
9567
+ if (opts.shared !== void 0) patch.shareable = opts.shared;
9568
+ if (opts.resellable !== void 0) patch.resellable = opts.resellable;
9569
+ const uploads = attachments.media.length > 0 || attachments.pack !== null;
9570
+ if (Object.keys(patch).length === 0 && !uploads) {
9571
+ const flags = ["--rename", "--price", "--icon", "--image", "--model", "--package", "--description", "--tag", "--clear-tags", "--shared"].map((f) => c.cyan(f));
9572
+ return this.refuse(usageRefusal(`Nothing to change. Pass ${flags.join(", ")} or ${c.cyan("--resellable")}.`));
9573
+ }
9574
+ let sku = null;
9575
+ if (Object.keys(patch).length > 0) {
9576
+ const res = await this.game.call(this.skuUrl(skuId), { method: "PATCH", body: JSON.stringify(patch) });
9577
+ if (!res.ok) return this.fail(res, "update that item", skuId);
9578
+ sku = await res.json();
9579
+ }
9580
+ const attached = await this.attach(skuId, sku, attachments);
9581
+ const finished = attached?.sku;
9582
+ if (!finished) return;
9583
+ if (opts.json) return this.printDraft(finished);
9584
+ log.success(`Updated ${c.cyan(finished.name)} \u2014 ${coinPrice(finished.priceCoins, finished.priceDisplayUsdCents)}.`);
9585
+ if (opts.resellable === false) log.dim(" Listings already on the market stay up.");
9586
+ if (opts.shared === false) log.dim(" Games that already added it keep it; no new game can add it.");
9587
+ }
9588
+ /**
9589
+ * Upload the picture, the model and the package to an item that exists:
9590
+ * upload-url → PUT to the signed URL → commit, each answering the item as it
9591
+ * now stands. Resolves the item after the last commit (`sku` itself when
9592
+ * there was nothing to upload), or null when an upload was refused —
9593
+ * printed, with the item's id and the command that finishes it, because the
9594
+ * item itself already exists.
9595
+ */
9596
+ async attach(skuId, sku, attachments) {
9597
+ let current = sku;
9598
+ const skuUrl = this.skuUrl(skuId);
9599
+ const parts = [
9600
+ ...attachments.media.map((m) => ({ flag: m.kind === MediaKind.Picture ? "--image" : "--model", source: m.file, kind: m.kind, bytes: m.bytes, contentType: m.contentType })),
9601
+ ...attachments.pack ? [{ flag: "--package", source: this.opts.packageDir ?? "<dir>", kind: "package", bytes: attachments.pack.zip, contentType: MediaContentType.Zip }] : []
9602
+ ];
9603
+ if (attachments.pack) this.reportSkipped(attachments.pack);
9604
+ for (const part of parts) {
9605
+ const unfinished = (refusal2, printed = false) => {
9606
+ const file = REFUSED_FILE_CODES.has(refusal2.error) ? ANOTHER_FILE[part.kind] : part.source;
9607
+ const finish2 = `The item ${skuId} exists, but its ${part.kind} did not upload. Finish it with: genex shop set ${skuId} ${part.flag} ${file}`;
9608
+ reportItemRefusal(this.log, this.opts.json, { ...refusal2, id: skuId, hints: [...refusal2.hints ?? [], finish2] }, printed);
9609
+ if (printed) this.log.error(finish2);
9610
+ return null;
9611
+ };
9612
+ const refused = async (res, what) => {
9613
+ const printed = printedStructuredError(res);
9614
+ return unfinished(await this.refusalFor(res, what, skuId, printed), printed);
9615
+ };
9616
+ const isPackage = part.kind === "package";
9617
+ const base = isPackage ? `${skuUrl}/package` : `${skuUrl}/media`;
9618
+ this.log.dim(`Uploading the ${part.kind} (${part.bytes.length} bytes)\u2026`);
9619
+ const urlRes = await this.post(`${base}/upload-url`, isPackage ? { bytes: part.bytes.length } : { kind: part.kind, bytes: part.bytes.length, contentType: part.contentType });
9620
+ if (!urlRes.ok) return refused(urlRes, `upload the ${part.kind}`);
9621
+ const { uploadUrl, key } = await urlRes.json();
9622
+ try {
9623
+ await putSigned(uploadUrl, part.bytes, part.contentType);
9624
+ } catch (err) {
9625
+ return unfinished({ error: "media_upload_failed", field: part.kind, message: err instanceof Error ? err.message : String(err) });
9626
+ }
9627
+ const commit = await this.post(base, isPackage ? { key } : { kind: part.kind, key });
9628
+ if (!commit.ok) return refused(commit, `attach the ${part.kind}`);
9629
+ current = await commit.json();
9630
+ }
9631
+ return { sku: current };
9632
+ }
9633
+ reportSkipped(pack2) {
9634
+ this.log.dim(`Packaging ${pack2.files.length} file${pack2.files.length === 1 ? "" : "s"}.`);
9635
+ if (pack2.skipped.length === 0) return;
9636
+ this.log.dim("Left out of the package:");
9637
+ for (const s of pack2.skipped) this.log.dim(` ${s.path} (${SKIP_REASON[s.reason]})`);
9638
+ }
9639
+ async testGrant(skuId) {
9640
+ const { log } = this;
9641
+ const res = await this.post(`${this.skuUrl(skuId)}/test-grant`);
9642
+ if (!res.ok) return this.fail(res, "grant yourself a test copy", skuId);
9643
+ const got = await res.json();
8701
9644
  log.success(`You now own ${c.cyan(got.name)} in this game.`);
8702
9645
  log.dim(" It cost nothing and no sale was recorded \u2014 it is a test copy.");
8703
9646
  log.dim(
8704
9647
  got.skuType === "durable" ? " Reload the game: it should appear in your inventory." : " Reload the game: it should be delivered and consumed once."
8705
9648
  );
8706
- return;
8707
9649
  }
8708
- if (sub === "set") {
8709
- const patch = {};
8710
- if (opts.rename) patch.name = opts.rename;
8711
- if (opts.price) patch.priceCoins = opts.price;
8712
- if (opts.icon) patch.iconUrl = opts.icon;
8713
- if (opts.resellable !== void 0) patch.resellable = opts.resellable;
8714
- if (Object.keys(patch).length === 0) {
8715
- log.error(
8716
- `Nothing to change. Pass ${c.cyan("--rename")}, ${c.cyan("--price")}, ${c.cyan("--icon")} or ${c.cyan("--resellable")}.`
8717
- );
8718
- process.exitCode = 1;
9650
+ async publish(skuId) {
9651
+ const res = await this.post(`${this.skuUrl(skuId)}/publish`);
9652
+ if (!res.ok) return this.fail(res, "put that item on sale", skuId);
9653
+ const sku = await res.json();
9654
+ if (this.opts.json) return this.printJson(sku);
9655
+ this.log.success(`${c.cyan(sku.name)} is on sale \u2014 ${coinPrice(sku.priceCoins, sku.priceDisplayUsdCents)}.`);
9656
+ this.log.dim(" Players can buy it in this game's store now.");
9657
+ }
9658
+ async unpublish(skuId) {
9659
+ const res = await this.post(`${this.skuUrl(skuId)}/unpublish`);
9660
+ if (!res.ok) return this.fail(res, "take that item off sale", skuId);
9661
+ const sku = await res.json();
9662
+ if (this.opts.json) return this.printJson(sku);
9663
+ this.log.success(`${c.cyan(sku.name)} is off sale.`);
9664
+ this.log.dim(" Players who already own it keep it.");
9665
+ }
9666
+ async remove(skuId) {
9667
+ const res = await this.game.call(this.skuUrl(skuId), { method: "DELETE" });
9668
+ if (!res.ok) return this.fail(res, "remove that item", skuId);
9669
+ this.log.success("Removed from the shop.");
9670
+ this.log.dim(" Players who already bought it keep it.");
9671
+ }
9672
+ };
9673
+
9674
+ // src/commands/items.ts
9675
+ import crypto6 from "crypto";
9676
+ import fs26 from "fs/promises";
9677
+ import path25 from "path";
9678
+ var SUBS2 = ["search", "add", "remove", "list"];
9679
+ var ITEMS_DIR = SHARED_ITEMS_DIR;
9680
+ var REPLACED_DIR = ".genex/replaced-items";
9681
+ var ITEM_FILE = ITEM_FILE_NAME;
9682
+ var ITEM_ID = /^[A-Za-z0-9_-]{1,64}$/;
9683
+ async function runItems(opts) {
9684
+ const log = createLogger({ quiet: opts.quiet, toStderr: opts.json });
9685
+ const usage = (message, hints) => reportItemRefusal(log, opts.json, { error: ItemErrorCode.Usage, message, ...hints ? { hints } : {} });
9686
+ const sub = opts.name?.trim() || "list";
9687
+ if (!SUBS2.includes(sub)) return usage(`Unknown subcommand ${c.cyan(sub)}. Use: ${SUBS2.join(", ")}.`);
9688
+ const arg = opts.query?.trim() ?? "";
9689
+ if (sub === "search" && !arg && !opts.itemTags?.length) {
9690
+ return usage(`\`genex items search\` needs words or a --tag, e.g. ${c.cyan('genex items search "wings" --tag fire')}.`);
9691
+ }
9692
+ if ((sub === "add" || sub === "remove") && !ITEM_ID.test(arg)) {
9693
+ return usage(arg ? `${JSON.stringify(arg)} isn't an item id.` : `\`genex items ${sub}\` needs an item id.`, [
9694
+ `Find one with ${c.cyan('genex items search "<words>"')} (or ${c.cyan("genex items list")}).`
9695
+ ]);
9696
+ }
9697
+ const game = await openLinkedGame(opts, log, `genex items ${sub}`);
9698
+ if (!game) return;
9699
+ const items = new ItemsSession(game, log, opts);
9700
+ if (sub === "search") return items.search(arg);
9701
+ if (sub === "add") return items.add(arg);
9702
+ if (sub === "remove") return items.remove(arg);
9703
+ return items.list();
9704
+ }
9705
+ var ADD_REFUSAL_HINT = {
9706
+ not_shareable: "Its author no longer lets other games add it; games that already did keep it.",
9707
+ home_game: "This is the item's own game: it is already in its store.",
9708
+ item_off_sale: "Only an item on sale can be added.",
9709
+ taken_down: "Only an item on sale can be added."
9710
+ };
9711
+ function sharePercent(bps) {
9712
+ if (typeof bps !== "number" || !Number.isInteger(bps) || bps < 0 || bps > 1e4) return null;
9713
+ return `${Number((bps / 100).toFixed(2))}%`;
9714
+ }
9715
+ var ItemsSession = class _ItemsSession {
9716
+ game;
9717
+ log;
9718
+ opts;
9719
+ allowances;
9720
+ constructor(game, log, opts) {
9721
+ this.game = game;
9722
+ this.log = log;
9723
+ this.opts = opts;
9724
+ this.allowances = `${game.apiUrl}/api/store/projects/${encodeURIComponent(game.meta.id)}/allowances`;
9725
+ }
9726
+ printJson(value) {
9727
+ writeSafely(process.stdout, `${JSON.stringify(value, null, 2)}
9728
+ `);
9729
+ }
9730
+ /** One place to turn an API refusal into a sentence (and, under --json, the refusal document). */
9731
+ async fail(res, what) {
9732
+ const printed = printedStructuredError(res);
9733
+ const refusal2 = refusalFromBody(res.status, await bodyOf(res), `Couldn't ${what}`);
9734
+ reportItemRefusal(this.log, this.opts.json, _ItemsSession.worded(res.status, refusal2), printed);
9735
+ }
9736
+ /** The refusal in this command's words: the store switched off, and the hints a host can act on. */
9737
+ static worded(status2, refusal2) {
9738
+ if (status2 === 404 && refusal2.error === "not_found") return { ...refusal2, message: "The item store isn't enabled on this environment yet." };
9739
+ const hint = ADD_REFUSAL_HINT[refusal2.error];
9740
+ return hint ? { ...refusal2, hints: [hint] } : refusal2;
9741
+ }
9742
+ /** Print a refusal this command decided itself (exit code 1). */
9743
+ refuse(refusal2) {
9744
+ reportItemRefusal(this.log, this.opts.json, refusal2);
9745
+ }
9746
+ async search(words2) {
9747
+ const qs = new URLSearchParams();
9748
+ if (words2) qs.set("q", words2);
9749
+ if (this.opts.itemTags?.length) qs.set("tags", this.opts.itemTags.join(","));
9750
+ const res = await this.game.call(`${this.game.apiUrl}/api/store/catalog?${qs}`);
9751
+ if (!res.ok) return this.fail(res, "search the items");
9752
+ const body = await res.json();
9753
+ if (this.opts.json) return this.printJson(body);
9754
+ const { log } = this;
9755
+ if (body.items.length === 0) {
9756
+ log.plain("No shared items match.");
8719
9757
  return;
8720
9758
  }
8721
- const res2 = await call2(skuUrl, { method: "PATCH", body: JSON.stringify(patch) });
8722
- if (!res2.ok) return fail5(res2, "update that item");
8723
- const sku = await res2.json();
8724
- log.success(`Updated ${c.cyan(sku.name)} \u2014 ${sku.priceCoins} coin.`);
8725
- if (opts.resellable === false) log.dim(" Listings already on the market stay up.");
9759
+ const share = sharePercent(body.sellerShareBps);
9760
+ if (share) log.plain(` Your game gets ${share} of every sale of an item it adds; the author and Genex share the rest.`);
9761
+ for (const item of body.items) {
9762
+ const reach = item.gamesCount === 1 ? "in 1 game" : `in ${item.gamesCount} games`;
9763
+ log.plain(` ${c.cyan(item.id)} ${item.name} \u2014 ${coinPrice(item.priceCoins, item.priceDisplayUsdCents)} \xB7 ${reach} \xB7 by ${item.author.name}`);
9764
+ log.dim(` you get ${formatCoins(item.youGetPerSale)} coin per sale${item.tags.length ? ` \xB7 ${item.tags.join(", ")}` : ""}`);
9765
+ if (item.description) log.dim(` ${item.description.length > 120 ? `${item.description.slice(0, 117)}\u2026` : item.description}`);
9766
+ }
9767
+ if (body.nextCursor) log.dim(" More match: narrow the search with more words or a --tag.");
9768
+ log.dim(` Add one to this game: ${c.cyan(`genex items add ${body.items[0]?.id ?? "<id>"}`)} (free; players who own it can use it here)`);
9769
+ }
9770
+ /**
9771
+ * Allow the item here, then write its folder: the package (validated whole)
9772
+ * and item.json. The allowance is server state; when the folder cannot be
9773
+ * written the item stays added and the command says how to fetch it again.
9774
+ */
9775
+ async add(skuId) {
9776
+ const { log } = this;
9777
+ const added = await this.game.call(this.allowances, { method: "POST", body: JSON.stringify({ skuId }) });
9778
+ if (!added.ok) return this.fail(added, "add that item");
9779
+ const { allowance, package: link, replayed } = await added.json();
9780
+ const item = await this.describe(skuId);
9781
+ if (!item) return;
9782
+ const pkg = link ? await this.packageLink(skuId) : null;
9783
+ if (link && !pkg) return;
9784
+ const itemFile = { ...item, manifest: pkg?.manifest ?? null, addedAt: allowance.addedAt };
9785
+ let files;
9786
+ try {
9787
+ files = await this.writeItemFolder(skuId, itemFile, pkg);
9788
+ } catch (err) {
9789
+ if (err instanceof ItemPackageError) return this.refuse(packageRefusal(skuId, item.name, err));
9790
+ if (err instanceof FolderRefused) return this.refuse(folderRefusal(skuId, item.name, err));
9791
+ throw err;
9792
+ }
9793
+ const dir = `${ITEMS_DIR}/${skuId}`;
9794
+ if (this.opts.json) return this.printJson({ allowance, item: itemFile, dir, files, replayed });
9795
+ const author = item.author ? ` by ${item.author.name}` : "";
9796
+ log.success(`${c.cyan(item.name)}${author} is added to this game \u2014 ${coinPrice(item.priceCoins, item.priceDisplayUsdCents)}.`);
9797
+ log.plain(` ${dir}/${ITEM_FILE}${files.length ? ` and its package (${files.length} file${files.length === 1 ? "" : "s"})` : ""}.`);
9798
+ log.dim(" Another creator's files: use them as reference material, and review any code before you run it.");
9799
+ log.dim(` Next, follow the genex-items skill: decide how it looks and works in this game, wire it to`);
9800
+ log.dim(` buy({ skuId: "${skuId}" }) and apply it from getEntitlements() at boot.`);
9801
+ log.dim(" It shows in this game's store once you release a new build.");
9802
+ log.dim(` Release one with ${c.cyan("genex publish")} (or ${c.cyan("genex preview")}, then ${c.cyan("genex promote")}) once it works in the game.`);
9803
+ }
9804
+ /**
9805
+ * The item's details for item.json. Browse's detail first; the caller's own
9806
+ * item is not browsable (contract D29), so it falls back to this game's
9807
+ * allowances, which carry everything but the description, tags and type.
9808
+ */
9809
+ async describe(skuId) {
9810
+ const res = await this.game.call(`${this.game.apiUrl}/api/store/catalog/${encodeURIComponent(skuId)}`);
9811
+ if (res.ok) {
9812
+ const { item } = await res.json();
9813
+ return {
9814
+ skuId,
9815
+ name: item.name,
9816
+ description: item.description,
9817
+ type: item.type,
9818
+ tags: item.tags,
9819
+ iconUrl: item.iconUrl,
9820
+ modelUrl: item.modelUrl,
9821
+ priceCoins: item.priceCoins,
9822
+ priceDisplayUsdCents: item.priceDisplayUsdCents,
9823
+ author: item.author,
9824
+ homeGame: item.homeGame
9825
+ };
9826
+ }
9827
+ if (res.status !== 404) {
9828
+ await this.fail(res, "read that item");
9829
+ return null;
9830
+ }
9831
+ const list = await this.game.call(this.allowances);
9832
+ if (!list.ok) {
9833
+ await this.fail(list, "read this game's items");
9834
+ return null;
9835
+ }
9836
+ const { items } = await list.json();
9837
+ const row = items.find((r) => r.item.id === skuId);
9838
+ if (!row) {
9839
+ this.refuse({ error: "sku_not_found", message: `Couldn't read ${skuId} back from this game's items.` });
9840
+ return null;
9841
+ }
9842
+ return {
9843
+ skuId,
9844
+ name: row.item.name,
9845
+ description: null,
9846
+ // Only a durable can be shared (P3).
9847
+ type: "durable",
9848
+ tags: [],
9849
+ iconUrl: row.item.iconUrl,
9850
+ modelUrl: row.item.modelUrl,
9851
+ priceCoins: row.item.priceCoins,
9852
+ priceDisplayUsdCents: row.item.priceDisplayUsdCents,
9853
+ author: row.item.author,
9854
+ homeGame: row.item.homeGame
9855
+ };
9856
+ }
9857
+ /** The package's signed link and its manifest, read together so the two always describe the same upload. */
9858
+ async packageLink(skuId) {
9859
+ const res = await this.game.call(`${this.game.apiUrl}/api/store/items/${encodeURIComponent(skuId)}/package`);
9860
+ if (!res.ok) {
9861
+ await this.fail(res, "fetch the item's package");
9862
+ return null;
9863
+ }
9864
+ return await res.json();
9865
+ }
9866
+ /**
9867
+ * Write `genex-items/<skuId>/`: download and unpack into a fresh staging
9868
+ * folder beside it, add item.json, then swap it in — moving a previous copy
9869
+ * under `.genex/` first. A refusal anywhere leaves the game folder as it was.
9870
+ */
9871
+ async writeItemFolder(skuId, itemFile, pkg) {
9872
+ const root = process.cwd();
9873
+ const itemsDir = path25.join(root, ITEMS_DIR);
9874
+ await ensureRealDir(itemsDir, ITEMS_DIR);
9875
+ const final = path25.join(itemsDir, skuId);
9876
+ const existing = await lstatOrNull(final);
9877
+ if (existing && !existing.isDirectory()) {
9878
+ throw new FolderRefused(`${ITEMS_DIR}/${skuId} is a link or a file, not a folder. Move it aside first; nothing was written through it.`);
9879
+ }
9880
+ const keep = existing ? await replacedItemsDir(root) : null;
9881
+ const zip = pkg ? await downloadItemPackage(pkg.url) : null;
9882
+ const staging = path25.join(itemsDir, `.${skuId}.incoming-${crypto6.randomBytes(4).toString("hex")}`);
9883
+ try {
9884
+ const files = zip && pkg ? await extractItemPackage(zip, staging, pkg.manifest) : [];
9885
+ if (!zip) await fs26.mkdir(staging);
9886
+ await fs26.writeFile(path25.join(staging, ITEM_FILE), `${JSON.stringify(itemFile, null, 2)}
9887
+ `, { flag: "wx" });
9888
+ if (keep) await fs26.rename(final, path25.join(keep, `${skuId}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`));
9889
+ await fs26.rename(staging, final);
9890
+ return files;
9891
+ } finally {
9892
+ await fs26.rm(staging, { recursive: true, force: true });
9893
+ }
9894
+ }
9895
+ async remove(skuId) {
9896
+ const res = await this.game.call(`${this.allowances}/${encodeURIComponent(skuId)}`, { method: "DELETE" });
9897
+ if (!res.ok) return this.fail(res, "remove that item");
9898
+ const body = await res.json();
9899
+ if (this.opts.json) return this.printJson(body);
9900
+ this.log.success(`Removed ${c.cyan(skuId)} from this game's store.`);
9901
+ this.log.dim(" Players who own it keep it; in this game it shows as Not in this game.");
9902
+ this.log.dim(` ${ITEMS_DIR}/${skuId}/ is still on disk: take its code out of the game, then delete the folder.`);
9903
+ }
9904
+ async list() {
9905
+ const res = await this.game.call(this.allowances);
9906
+ if (!res.ok) return this.fail(res, "read this game's items");
9907
+ const body = await res.json();
9908
+ if (this.opts.json) return this.printJson(body);
9909
+ const { log } = this;
9910
+ if (body.items.length === 0) {
9911
+ log.plain("This game has added no shared items.");
9912
+ log.dim(` Find one: ${c.cyan('genex items search "<words>"')}`);
9913
+ return;
9914
+ }
9915
+ for (const { allowance, item } of body.items) {
9916
+ const author = item.author ? ` \xB7 by ${item.author.name}` : "";
9917
+ const live = allowance.liveFromBuildAt ? "" : c.dim(" \xB7 not live yet: it reaches the store with the next published build");
9918
+ log.plain(` ${c.cyan(item.id)} ${item.name} \u2014 ${coinPrice(item.priceCoins, item.priceDisplayUsdCents)}${author}${live}`);
9919
+ }
9920
+ }
9921
+ };
9922
+ var FolderRefused = class extends Error {
9923
+ };
9924
+ function packageRefusal(skuId, name, err) {
9925
+ return {
9926
+ error: "package_invalid",
9927
+ field: "package",
9928
+ message: `The package was refused: ${err.message}`,
9929
+ id: skuId,
9930
+ hints: [
9931
+ `${name} is added to this game, but nothing was written to ${ITEMS_DIR}/${skuId}/.`,
9932
+ `The package is its author's to fix, not yours. Try again later: ${c.cyan(`genex items add ${skuId}`)}`,
9933
+ "If it keeps failing, report the item as Doesn't work from its game's store, or pick another item."
9934
+ ]
9935
+ };
9936
+ }
9937
+ function folderRefusal(skuId, name, err) {
9938
+ return {
9939
+ error: ItemErrorCode.FolderRefused,
9940
+ message: err.message,
9941
+ id: skuId,
9942
+ hints: [`${name} is added to this game, but nothing was written to ${ITEMS_DIR}/${skuId}/. Fix that, then run: genex items add ${skuId}`]
9943
+ };
9944
+ }
9945
+ async function lstatOrNull(p) {
9946
+ try {
9947
+ return await fs26.lstat(p);
9948
+ } catch (err) {
9949
+ if (err.code === "ENOENT") return null;
9950
+ throw err;
9951
+ }
9952
+ }
9953
+ async function replacedItemsDir(root) {
9954
+ await fs26.mkdir(path25.join(root, ".genex"), { recursive: true });
9955
+ const keep = path25.join(root, ...REPLACED_DIR.split("/"));
9956
+ await ensureRealDir(keep, REPLACED_DIR);
9957
+ return keep;
9958
+ }
9959
+ async function ensureRealDir(dir, label) {
9960
+ const stat = await lstatOrNull(dir);
9961
+ if (!stat) {
9962
+ await fs26.mkdir(dir);
8726
9963
  return;
8727
9964
  }
8728
- const res = await call2(skuUrl, { method: "DELETE" });
8729
- if (!res.ok) return fail5(res, "remove that item");
8730
- log.success("Removed from the shop.");
8731
- log.dim(" Players who already bought it keep it.");
9965
+ if (!stat.isDirectory()) {
9966
+ throw new FolderRefused(`${label} is a link or a file, not a folder. Move it aside first; nothing was written through it.`);
9967
+ }
8732
9968
  }
8733
9969
 
8734
9970
  // src/lib/promote.ts
@@ -8922,6 +10158,8 @@ async function runPublish(opts) {
8922
10158
  process.exitCode = 1;
8923
10159
  return;
8924
10160
  }
10161
+ const listedOnDisk = await readProject();
10162
+ await writeProject({ ...listedOnDisk ?? meta, status: "published" });
8925
10163
  const listed = await res.json().catch(() => null);
8926
10164
  if (opts.regenerateCover && listed?.project?.coverAiEnabled === false) {
8927
10165
  log.dim(" --regenerate-cover did nothing: this stand doesn't make AI covers.");
@@ -9217,8 +10455,8 @@ async function runRollback(opts) {
9217
10455
  }
9218
10456
 
9219
10457
  // src/commands/cover.ts
9220
- import fs24 from "fs/promises";
9221
- import path24 from "path";
10458
+ import fs27 from "fs/promises";
10459
+ import path26 from "path";
9222
10460
  var WHAT_A_COVER_IS = "one real frame of the game: its signature moment, real gameplay, well lit, no menus, popups or busy HUD, landscape 16:9, no text";
9223
10461
  async function runCover(opts) {
9224
10462
  const log = createLogger({ quiet: opts.quiet || opts.json });
@@ -9243,7 +10481,7 @@ async function runCover(opts) {
9243
10481
  }
9244
10482
  let bytes;
9245
10483
  try {
9246
- const stat = await fs24.stat(file);
10484
+ const stat = await fs27.stat(file);
9247
10485
  if (!stat.isFile()) throw new Error("not a file");
9248
10486
  if (stat.size > COVER_MAX_BYTES) {
9249
10487
  return finish(
@@ -9253,43 +10491,43 @@ async function runCover(opts) {
9253
10491
  log
9254
10492
  );
9255
10493
  }
9256
- bytes = await fs24.readFile(file);
10494
+ bytes = await fs27.readFile(file);
9257
10495
  } catch {
9258
10496
  return finish({ kind: "invalid", message: `Can't read ${file} \u2014 pass the path of a PNG, JPEG or WebP image.` }, file, opts, log);
9259
10497
  }
9260
- log.step(`Uploading ${path24.basename(file)} as the cover\u2026`);
10498
+ log.step(`Uploading ${path26.basename(file)} as the cover\u2026`);
9261
10499
  const outcome2 = await uploadCover({ apiUrl, token, projectId: meta.id, bytes, label: file });
9262
10500
  await rememberNomination(bytes, file, outcome2);
9263
10501
  finish(outcome2, file, opts, log);
9264
10502
  }
9265
10503
  async function rememberNomination(bytes, file, outcome2) {
9266
10504
  const scratch = await findScratchCover();
9267
- if (!scratch || path24.resolve(scratch) === path24.resolve(file)) {
10505
+ if (!scratch || path26.resolve(scratch) === path26.resolve(file)) {
9268
10506
  await rememberCoverUpload(bytes, file, outcome2);
9269
10507
  return;
9270
10508
  }
9271
10509
  if (outcome2.kind !== "applied" && outcome2.kind !== "outranked") return;
9272
- const stale = await fs24.readFile(scratch).catch(() => null);
10510
+ const stale = await fs27.readFile(scratch).catch(() => null);
9273
10511
  if (stale) await rememberCoverUpload(stale, scratch, outcome2);
9274
10512
  }
9275
10513
  async function sendClip(apiUrl, token, projectId, clip, opts, log) {
9276
10514
  const file = clip ?? "";
9277
- const invalid = (message) => finishClip({ kind: "invalid", message }, file, opts, log);
9278
- if (!clip) return invalid("--commit goes with --clip <file>: pass the recording to send.");
9279
- if (opts.imagePath?.trim()) return invalid("Pass either an image (the cover) or --clip <file> (the cover clip), not both.");
9280
- if (!opts.commit?.trim()) return invalid("--clip needs --commit <sha>: the git commit of the build the recording shows.");
10515
+ const invalid2 = (message) => finishClip({ kind: "invalid", message }, file, opts, log);
10516
+ if (!clip) return invalid2("--commit goes with --clip <file>: pass the recording to send.");
10517
+ if (opts.imagePath?.trim()) return invalid2("Pass either an image (the cover) or --clip <file> (the cover clip), not both.");
10518
+ if (!opts.commit?.trim()) return invalid2("--clip needs --commit <sha>: the git commit of the build the recording shows.");
9281
10519
  let bytes;
9282
10520
  try {
9283
- const stat = await fs24.stat(clip);
10521
+ const stat = await fs27.stat(clip);
9284
10522
  if (!stat.isFile()) throw new Error("not a file");
9285
10523
  if (stat.size > CLIP_MAX_BYTES) {
9286
- return invalid(`${clip} is ${(stat.size / 1024 / 1024).toFixed(1)} MB; a cover clip recording can be at most ${CLIP_MAX_BYTES / 1024 / 1024} MB.`);
10524
+ return invalid2(`${clip} is ${(stat.size / 1024 / 1024).toFixed(1)} MB; a cover clip recording can be at most ${CLIP_MAX_BYTES / 1024 / 1024} MB.`);
9287
10525
  }
9288
- bytes = await fs24.readFile(clip);
10526
+ bytes = await fs27.readFile(clip);
9289
10527
  } catch {
9290
- return invalid(`Can't read ${clip} \u2014 pass the path of an MP4 or WebM recording.`);
10528
+ return invalid2(`Can't read ${clip} \u2014 pass the path of an MP4 or WebM recording.`);
9291
10529
  }
9292
- log.step(`Uploading ${path24.basename(clip)} as the cover clip\u2026`);
10530
+ log.step(`Uploading ${path26.basename(clip)} as the cover clip\u2026`);
9293
10531
  const outcome2 = await uploadClip({ apiUrl, token, projectId, bytes, label: clip, buildCommit: opts.commit });
9294
10532
  finishClip(outcome2, clip, opts, log);
9295
10533
  }
@@ -9347,8 +10585,8 @@ async function showCover(apiUrl, token, slug, opts, log) {
9347
10585
  }
9348
10586
 
9349
10587
  // src/commands/generate.ts
9350
- import fs27 from "fs/promises";
9351
- import path27 from "path";
10588
+ import fs30 from "fs/promises";
10589
+ import path29 from "path";
9352
10590
  import { PNG as PNG4 } from "pngjs";
9353
10591
 
9354
10592
  // src/lib/glass.ts
@@ -9535,13 +10773,13 @@ function ceilingVerdict(input) {
9535
10773
  }
9536
10774
 
9537
10775
  // src/lib/generation-admission.ts
9538
- import fs26 from "fs/promises";
9539
- import path26 from "path";
9540
- import { createHash, randomUUID } from "crypto";
10776
+ import fs29 from "fs/promises";
10777
+ import path28 from "path";
10778
+ import { createHash as createHash2, randomUUID } from "crypto";
9541
10779
 
9542
10780
  // src/lib/asset-budget.ts
9543
- import fs25 from "fs/promises";
9544
- import path25 from "path";
10781
+ import fs28 from "fs/promises";
10782
+ import path27 from "path";
9545
10783
  var DEFAULT_ASSET_BUDGET_SHARE = 0.5;
9546
10784
  var DEFAULT_ASSET_BUDGET_CAP = 500;
9547
10785
  function envShare(raw, fallback) {
@@ -9568,10 +10806,10 @@ function allowanceFor(input) {
9568
10806
  if (!Number.isFinite(spendable) || spendable <= 0) return 0;
9569
10807
  return Math.max(0, Math.min(Math.floor(spendable * config.share), config.cap));
9570
10808
  }
9571
- var storePath = (cwd) => path25.join(cwd, ".genex", "asset-budget.json");
10809
+ var storePath = (cwd) => path27.join(cwd, ".genex", "asset-budget.json");
9572
10810
  async function readBudgetStore(cwd = process.cwd()) {
9573
10811
  try {
9574
- const raw = await fs25.readFile(storePath(cwd), "utf8");
10812
+ const raw = await fs28.readFile(storePath(cwd), "utf8");
9575
10813
  const parsed = JSON.parse(raw);
9576
10814
  if (!parsed || parsed.v !== 1 || typeof parsed.allowanceCredits !== "number" || !Number.isFinite(parsed.allowanceCredits) || parsed.setBy !== "default" && parsed.setBy !== "user") {
9577
10815
  return null;
@@ -9590,8 +10828,8 @@ async function readBudgetStore(cwd = process.cwd()) {
9590
10828
  }
9591
10829
  async function writeBudgetStore(cwd, store) {
9592
10830
  try {
9593
- await fs25.access(path25.join(cwd, ".genex"));
9594
- await fs25.writeFile(storePath(cwd), `${JSON.stringify(store, null, 2)}
10831
+ await fs28.access(path27.join(cwd, ".genex"));
10832
+ await fs28.writeFile(storePath(cwd), `${JSON.stringify(store, null, 2)}
9595
10833
  `, "utf8");
9596
10834
  return true;
9597
10835
  } catch {
@@ -9602,7 +10840,7 @@ async function ensureBudgetStore(cwd, input) {
9602
10840
  const existing = await readBudgetStore(cwd);
9603
10841
  if (existing) return existing;
9604
10842
  try {
9605
- await fs25.stat(storePath(cwd));
10843
+ await fs28.stat(storePath(cwd));
9606
10844
  throw new Error("Stored asset allowance is unreadable or invalid; reconcile it before paid generation.");
9607
10845
  } catch (error) {
9608
10846
  if (error.code !== "ENOENT") throw error;
@@ -9706,13 +10944,13 @@ async function assetBudgetGate(input) {
9706
10944
 
9707
10945
  // src/lib/generation-admission.ts
9708
10946
  async function withAdmissionLock(cwd, work, waitMs = 3e4) {
9709
- const lock = path26.join(cwd, ".genex", "generation-admission.lock");
9710
- await fs26.mkdir(path26.dirname(lock), { recursive: true });
10947
+ const lock = path28.join(cwd, ".genex", "generation-admission.lock");
10948
+ await fs29.mkdir(path28.dirname(lock), { recursive: true });
9711
10949
  const deadline = Date.now() + waitMs;
9712
10950
  let file;
9713
10951
  while (!file) {
9714
10952
  try {
9715
- file = await fs26.open(lock, "wx");
10953
+ file = await fs29.open(lock, "wx");
9716
10954
  } catch (error) {
9717
10955
  if (error.code !== "EEXIST") throw error;
9718
10956
  if (Date.now() >= deadline) throw new Error("Another generation admission holds .genex/generation-admission.lock. If its process died, reconcile pending reservations before removing that lock.");
@@ -9724,7 +10962,7 @@ async function withAdmissionLock(cwd, work, waitMs = 3e4) {
9724
10962
  return await work();
9725
10963
  } finally {
9726
10964
  await file.close();
9727
- await fs26.unlink(lock);
10965
+ await fs29.unlink(lock);
9728
10966
  }
9729
10967
  }
9730
10968
  async function enqueueWithAllowance(input) {
@@ -9734,7 +10972,7 @@ async function enqueueWithAllowance(input) {
9734
10972
  const createPath = input.createPath ?? "/api/generations";
9735
10973
  const generic = createPath === "/api/generations";
9736
10974
  const headers = { "Content-Type": "application/json", Authorization: `Bearer ${input.token}` };
9737
- const fingerprint = createHash("sha256").update(JSON.stringify([input.apiUrl, createPath, input.body])).digest("hex");
10975
+ const fingerprint = createHash2("sha256").update(JSON.stringify([input.apiUrl, createPath, input.body])).digest("hex");
9738
10976
  const rows = await readAdmissionRows(cwd);
9739
10977
  const pending2 = /* @__PURE__ */ new Map();
9740
10978
  for (const row of rows) {
@@ -9973,7 +11211,7 @@ var isRemoteRef = (value) => /^(https?:\/\/|data:)/i.test(value);
9973
11211
  async function inlineLocalImage(filePath, flag) {
9974
11212
  let bytes;
9975
11213
  try {
9976
- bytes = await fs27.readFile(filePath);
11214
+ bytes = await fs30.readFile(filePath);
9977
11215
  } catch {
9978
11216
  return { ok: false, error: `Couldn't read the ${flag} file at ${filePath}.` };
9979
11217
  }
@@ -9983,7 +11221,7 @@ async function inlineLocalImage(filePath, flag) {
9983
11221
  error: `${flag} file is ${(bytes.length / 1048576).toFixed(1)} MB \u2014 over the ~4 MB inline limit. Downscale/compress it first, or pass an asset URL instead.`
9984
11222
  };
9985
11223
  }
9986
- const mime = IMAGE_MIME_BY_EXT[path27.extname(filePath).toLowerCase()] ?? "image/png";
11224
+ const mime = IMAGE_MIME_BY_EXT[path29.extname(filePath).toLowerCase()] ?? "image/png";
9987
11225
  return { ok: true, dataUri: `data:${mime};base64,${bytes.toString("base64")}` };
9988
11226
  }
9989
11227
  var SKYBOX_ENVIRONMENT_SUFFIX = ". The image contains ONLY sky: cloud, atmosphere, light, weather and distant haze at the horizon. Every structure, object, plant and ground surface is outside the frame.";
@@ -10178,7 +11416,7 @@ async function runGenerate(kind, opts) {
10178
11416
  let typedPrompt = opts.prompt?.trim();
10179
11417
  if (!typedPrompt && kind === "model" && opts.imageUrl) {
10180
11418
  const ref = opts.imageUrl.startsWith("data:") ? "local image" : opts.imageUrl;
10181
- typedPrompt = `from image: ${path27.basename(ref).slice(0, 120)}`;
11419
+ typedPrompt = `from image: ${path29.basename(ref).slice(0, 120)}`;
10182
11420
  }
10183
11421
  if (kind === "model" && opts.texture !== void 0 && !MODEL_TEXTURE_TIERS.includes(opts.texture)) {
10184
11422
  log.error(`--texture ${opts.texture} is a character texture size. \`genex model\` takes a texture TIER: ${MODEL_TEXTURE_TIERS.join("|")} (default detailed).`);
@@ -10227,7 +11465,7 @@ async function runGenerate(kind, opts) {
10227
11465
  return;
10228
11466
  }
10229
11467
  try {
10230
- const bytes = await fs27.readFile(opts.inpaintUrl);
11468
+ const bytes = await fs30.readFile(opts.inpaintUrl);
10231
11469
  opts = { ...opts, inpaintUrl: `data:image/png;base64,${bytes.toString("base64")}` };
10232
11470
  } catch {
10233
11471
  log.error(`Couldn't read the --inpaint mask at ${opts.inpaintUrl}.`);
@@ -10396,7 +11634,7 @@ async function reportGlassTerminal(view, outDir, log, json) {
10396
11634
  return;
10397
11635
  }
10398
11636
  await recordTerminal(view.id, "completed", files.map((f) => f.url));
10399
- await fs27.mkdir(outDir, { recursive: true });
11637
+ await fs30.mkdir(outDir, { recursive: true });
10400
11638
  const solved = [];
10401
11639
  for (let i = 0; i < files.length; i++) {
10402
11640
  const f = files[i];
@@ -10428,8 +11666,8 @@ async function reportGlassTerminal(view, outDir, log, json) {
10428
11666
  });
10429
11667
  continue;
10430
11668
  }
10431
- const outPath = path27.join(outDir, `glass-${i + 1}.png`);
10432
- await fs27.writeFile(outPath, PNG4.sync.write(r.png));
11669
+ const outPath = path29.join(outDir, `glass-${i + 1}.png`);
11670
+ await fs30.writeFile(outPath, PNG4.sync.write(r.png));
10433
11671
  solved.push({
10434
11672
  path: outPath,
10435
11673
  url: f.url,
@@ -11059,8 +12297,8 @@ function writeJson(value) {
11059
12297
  }
11060
12298
 
11061
12299
  // src/commands/model-sub.ts
11062
- import fs28 from "fs/promises";
11063
- import path28 from "path";
12300
+ import fs31 from "fs/promises";
12301
+ import path30 from "path";
11064
12302
  var MODEL_SUBCOMMANDS = ["segment", "rig", "animate", "import"];
11065
12303
  function apiErrorMessage(data, fallback) {
11066
12304
  if (typeof data !== "object" || data === null) return fallback;
@@ -11075,29 +12313,29 @@ async function importModelFile(args) {
11075
12313
  const { log } = args;
11076
12314
  const filePath = args.filePath.trim();
11077
12315
  if (!/\.glb$/i.test(filePath)) {
11078
- log.error(`\`import\` takes a .glb file (binary glTF 2.0). Export ${path28.basename(filePath) || "the model"} as GLB first \u2014 Blender: File \u2192 Export \u2192 glTF 2.0, format "glTF Binary".`);
12316
+ log.error(`\`import\` takes a .glb file (binary glTF 2.0). Export ${path30.basename(filePath) || "the model"} as GLB first \u2014 Blender: File \u2192 Export \u2192 glTF 2.0, format "glTF Binary".`);
11079
12317
  return null;
11080
12318
  }
11081
12319
  let bytes;
11082
12320
  try {
11083
- bytes = await fs28.readFile(filePath);
12321
+ bytes = await fs31.readFile(filePath);
11084
12322
  } catch {
11085
12323
  log.error(`Couldn't read ${filePath}.`);
11086
12324
  return null;
11087
12325
  }
11088
12326
  if (bytes.byteLength > MODEL_IMPORT_MAX_BYTES) {
11089
- log.error(`${path28.basename(filePath)} is ${(bytes.byteLength / 1e6).toFixed(1)} MB; imports are capped at ${MODEL_IMPORT_MAX_BYTES / 1024 / 1024} MB. Shrink its textures (they are usually the bulk) and try again.`);
12327
+ log.error(`${path30.basename(filePath)} is ${(bytes.byteLength / 1e6).toFixed(1)} MB; imports are capped at ${MODEL_IMPORT_MAX_BYTES / 1024 / 1024} MB. Shrink its textures (they are usually the bulk) and try again.`);
11090
12328
  return null;
11091
12329
  }
11092
12330
  if (bytes.byteLength < 12 || bytes.readUInt32LE(0) !== GLB_MAGIC) {
11093
- log.error(`${path28.basename(filePath)} is not a GLB (no glTF magic). A .gltf + .bin pair must be exported as one binary .glb.`);
12331
+ log.error(`${path30.basename(filePath)} is not a GLB (no glTF magic). A .gltf + .bin pair must be exported as one binary .glb.`);
11094
12332
  return null;
11095
12333
  }
11096
12334
  const headers = { "Content-Type": "application/json", Authorization: `Bearer ${args.token}` };
11097
12335
  const minted = await apiFetch(`${args.apiUrl}/api/generations/import`, {
11098
12336
  method: "POST",
11099
12337
  headers,
11100
- body: JSON.stringify({ filename: path28.basename(filePath), bytes: bytes.byteLength, contentType: "model/gltf-binary" })
12338
+ body: JSON.stringify({ filename: path30.basename(filePath), bytes: bytes.byteLength, contentType: "model/gltf-binary" })
11101
12339
  });
11102
12340
  if (printedStructuredError(minted)) return null;
11103
12341
  if (!minted.ok) {
@@ -11106,7 +12344,7 @@ async function importModelFile(args) {
11106
12344
  return null;
11107
12345
  }
11108
12346
  const { id, uploadUrl, url } = await minted.json();
11109
- log.dim(` uploading ${path28.basename(filePath)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
12347
+ log.dim(` uploading ${path30.basename(filePath)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
11110
12348
  const put = await fetch(uploadUrl, {
11111
12349
  method: "PUT",
11112
12350
  headers: { "Content-Type": "model/gltf-binary", "Content-Length": String(bytes.byteLength) },
@@ -11254,7 +12492,7 @@ async function runModelAnimate(opts) {
11254
12492
  }
11255
12493
 
11256
12494
  // src/commands/wait.ts
11257
- import path29 from "path";
12495
+ import path31 from "path";
11258
12496
  var MAX_TIMEOUT_SEC = 2e6;
11259
12497
  var STILL_RUNNING_EXIT = 2;
11260
12498
  var TERMINAL2 = /* @__PURE__ */ new Set(["completed", "failed"]);
@@ -11461,7 +12699,7 @@ async function runWaitAll(opts) {
11461
12699
  if (v?.status !== "completed" || !v.files?.length) continue;
11462
12700
  const local = localDeliveryFor("tools", { ...opts, outDir: opts.outDir ?? e.outDir }, e.prompt || e.kind);
11463
12701
  if (!local) continue;
11464
- const outDir = path29.join(path29.relative(process.cwd(), cwd) || ".", local.outDir);
12702
+ const outDir = path31.join(path31.relative(process.cwd(), cwd) || ".", local.outDir);
11465
12703
  const target = { kind: e.kind, prompt: local.prompt, id: e.id, outDir };
11466
12704
  const missing = await undeliveredFiles(v.files, target);
11467
12705
  if (missing.length === 0) continue;
@@ -11573,11 +12811,11 @@ async function toRow(e, v, cwd) {
11573
12811
  }
11574
12812
 
11575
12813
  // src/commands/controller.ts
11576
- import fs30 from "fs/promises";
11577
- import path31 from "path";
12814
+ import fs33 from "fs/promises";
12815
+ import path33 from "path";
11578
12816
 
11579
12817
  // ../../packages/meshy-animation-catalog/src/index.ts
11580
- import { createHash as createHash2 } from "crypto";
12818
+ import { createHash as createHash3 } from "crypto";
11581
12819
 
11582
12820
  // ../../packages/meshy-animation-catalog/src/generated.ts
11583
12821
  var MESHY_ANIMATION_CATALOG = [
@@ -20295,7 +21533,7 @@ function normalizedPackDefinition(definition) {
20295
21533
  return { key, version: definition.version, actionIds, bindings };
20296
21534
  }
20297
21535
  function packFingerprint(definition) {
20298
- return createHash2("sha256").update(JSON.stringify(definition)).digest("hex");
21536
+ return createHash3("sha256").update(JSON.stringify(definition)).digest("hex");
20299
21537
  }
20300
21538
  function createMeshyControllerPackSnapshot(definition) {
20301
21539
  const normalized = normalizedPackDefinition(definition);
@@ -20418,9 +21656,9 @@ function searchMeshyAnimations(query, options = {}) {
20418
21656
  }
20419
21657
 
20420
21658
  // src/lib/anims.ts
20421
- import fs29 from "fs/promises";
20422
- import path30 from "path";
20423
- var ANIMS_DEST = path30.join("public", "assets", "anims");
21659
+ import fs32 from "fs/promises";
21660
+ import path32 from "path";
21661
+ var ANIMS_DEST = path32.join("public", "assets", "anims");
20424
21662
  var HIDDEN_TAG = "reference";
20425
21663
  async function runAnims(opts) {
20426
21664
  const log = createLogger({ quiet: opts.quiet });
@@ -20436,7 +21674,7 @@ async function runAnims(opts) {
20436
21674
  printCatalog(log, manifest, selectors);
20437
21675
  return;
20438
21676
  }
20439
- const controllerMarker = path30.join(root, "src", "controllers", "character");
21677
+ const controllerMarker = path32.join(root, "src", "controllers", "character");
20440
21678
  if (!await exists3(controllerMarker)) {
20441
21679
  log.error(
20442
21680
  `No character controller in this game (missing ${c.cyan("src/controllers/character/")}).`
@@ -20445,11 +21683,11 @@ async function runAnims(opts) {
20445
21683
  process.exitCode = 1;
20446
21684
  return;
20447
21685
  }
20448
- const destDir = path30.join(root, ANIMS_DEST);
20449
- const gameManifestPath = path30.join(destDir, "manifest.json");
21686
+ const destDir = path32.join(root, ANIMS_DEST);
21687
+ const gameManifestPath = path32.join(destDir, "manifest.json");
20450
21688
  if (opts.reset) {
20451
- await fs29.rm(destDir, { recursive: true, force: true });
20452
- log.step(`Cleared ${c.cyan(ANIMS_DEST + path30.sep)} (--reset)`);
21689
+ await fs32.rm(destDir, { recursive: true, force: true });
21690
+ log.step(`Cleared ${c.cyan(ANIMS_DEST + path32.sep)} (--reset)`);
20453
21691
  }
20454
21692
  if (selectors.length === 0) {
20455
21693
  const installed = await readGameManifest(gameManifestPath);
@@ -20475,7 +21713,7 @@ async function runAnims(opts) {
20475
21713
  return;
20476
21714
  }
20477
21715
  const coreNames = new Set(manifest.core);
20478
- const byName = /* @__PURE__ */ new Map();
21716
+ const byName2 = /* @__PURE__ */ new Map();
20479
21717
  let bundledSkips = 0;
20480
21718
  for (const entries of resolved.values()) {
20481
21719
  for (const entry of entries) {
@@ -20483,39 +21721,39 @@ async function runAnims(opts) {
20483
21721
  bundledSkips++;
20484
21722
  continue;
20485
21723
  }
20486
- byName.set(entry.name, entry);
21724
+ byName2.set(entry.name, entry);
20487
21725
  }
20488
21726
  }
20489
- const wanted = [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
20490
- const cacheDir = path30.join(
21727
+ const wanted = [...byName2.values()].sort((a, b) => a.name.localeCompare(b.name));
21728
+ const cacheDir = path32.join(
20491
21729
  opts.cacheDir ?? getAnimsCacheDir(),
20492
21730
  `${manifest.library}-v${manifest.version}`
20493
21731
  );
20494
- await fs29.mkdir(cacheDir, { recursive: true });
20495
- await fs29.mkdir(destDir, { recursive: true });
21732
+ await fs32.mkdir(cacheDir, { recursive: true });
21733
+ await fs32.mkdir(destDir, { recursive: true });
20496
21734
  const base = getAnimsBase(opts.animsBase);
20497
21735
  let installedCount = 0;
20498
21736
  let presentCount = 0;
20499
21737
  let addedBytes = 0;
20500
21738
  const failures = [];
20501
21739
  for (const entry of wanted) {
20502
- const dest = path30.join(destDir, entry.file);
21740
+ const dest = path32.join(destDir, entry.file);
20503
21741
  if (await hasSize(dest, entry.bytes)) {
20504
21742
  presentCount++;
20505
21743
  continue;
20506
21744
  }
20507
21745
  try {
20508
- const cached = path30.join(cacheDir, entry.file);
21746
+ const cached = path32.join(cacheDir, entry.file);
20509
21747
  if (!await hasSize(cached, entry.bytes)) {
20510
21748
  const res = await fetch(base + entry.file);
20511
21749
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
20512
21750
  const buf = Buffer.from(await res.arrayBuffer());
20513
- await fs29.writeFile(cached, buf);
21751
+ await fs32.writeFile(cached, buf);
20514
21752
  }
20515
- await fs29.copyFile(cached, dest);
21753
+ await fs32.copyFile(cached, dest);
20516
21754
  installedCount++;
20517
21755
  addedBytes += entry.bytes;
20518
- log.dim(` ${path30.join(ANIMS_DEST, entry.file)} (${formatMb(entry.bytes)})`);
21756
+ log.dim(` ${path32.join(ANIMS_DEST, entry.file)} (${formatMb(entry.bytes)})`);
20519
21757
  } catch (err) {
20520
21758
  failures.push(`${entry.name} (${err instanceof Error ? err.message : String(err)})`);
20521
21759
  }
@@ -20531,13 +21769,13 @@ async function runAnims(opts) {
20531
21769
  version: manifest.version,
20532
21770
  clips: [...union].sort((a, b) => a.localeCompare(b))
20533
21771
  };
20534
- await fs29.writeFile(gameManifestPath, JSON.stringify(gameManifest, null, 2) + "\n");
21772
+ await fs32.writeFile(gameManifestPath, JSON.stringify(gameManifest, null, 2) + "\n");
20535
21773
  log.plain("");
20536
21774
  const parts = [`${installedCount} clip${installedCount === 1 ? "" : "s"} installed`];
20537
21775
  if (presentCount > 0) parts.push(`${presentCount} already present`);
20538
21776
  if (bundledSkips > 0) parts.push(`${bundledSkips} already bundled in animation-library.glb`);
20539
21777
  log.success(
20540
- `${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path30.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
21778
+ `${parts.join(", ")} \u2192 ${c.cyan(ANIMS_DEST + path32.sep)}${addedBytes > 0 ? ` (+${formatMb(addedBytes)})` : ""}`
20541
21779
  );
20542
21780
  for (const [selector, entries] of resolved) {
20543
21781
  const names2 = entries.filter((e) => !coreNames.has(e.name)).map((e) => e.name);
@@ -20569,12 +21807,12 @@ async function loadManifest(baseOverride) {
20569
21807
  }
20570
21808
  } catch {
20571
21809
  }
20572
- const snapshotPath = path30.join(getTemplatesDir(), "controllers", "assets", "anims-manifest.json");
20573
- const manifest = JSON.parse(await fs29.readFile(snapshotPath, "utf8"));
21810
+ const snapshotPath = path32.join(getTemplatesDir(), "controllers", "assets", "anims-manifest.json");
21811
+ const manifest = JSON.parse(await fs32.readFile(snapshotPath, "utf8"));
20574
21812
  return { manifest, source: "snapshot" };
20575
21813
  }
20576
21814
  function resolveSelectors(manifest, selectors) {
20577
- const byName = new Map(manifest.clips.map((entry) => [entry.name, entry]));
21815
+ const byName2 = new Map(manifest.clips.map((entry) => [entry.name, entry]));
20578
21816
  const byLowerName = new Map(manifest.clips.map((entry) => [entry.name.toLowerCase(), entry]));
20579
21817
  const tags2 = /* @__PURE__ */ new Map();
20580
21818
  for (const entry of manifest.clips) {
@@ -20586,7 +21824,7 @@ function resolveSelectors(manifest, selectors) {
20586
21824
  }
20587
21825
  const out = /* @__PURE__ */ new Map();
20588
21826
  for (const selector of selectors) {
20589
- const exact = byName.get(selector) ?? byLowerName.get(selector.toLowerCase());
21827
+ const exact = byName2.get(selector) ?? byLowerName.get(selector.toLowerCase());
20590
21828
  if (exact) {
20591
21829
  out.set(selector, [exact]);
20592
21830
  continue;
@@ -20596,7 +21834,7 @@ function resolveSelectors(manifest, selectors) {
20596
21834
  out.set(selector, tagHit);
20597
21835
  continue;
20598
21836
  }
20599
- const candidates = [...tags2.keys(), ...byName.keys()];
21837
+ const candidates = [...tags2.keys(), ...byName2.keys()];
20600
21838
  const close = suggest(selector, candidates);
20601
21839
  throw new Error(
20602
21840
  `unknown clip/tag "${selector}"${close.length > 0 ? ` \u2014 closest: ${close.join(", ")}` : ""}. Run ${c.cyan(
@@ -20688,21 +21926,21 @@ function printCatalog(log, manifest, selectors) {
20688
21926
  }
20689
21927
  async function readGameManifest(file) {
20690
21928
  try {
20691
- return JSON.parse(await fs29.readFile(file, "utf8"));
21929
+ return JSON.parse(await fs32.readFile(file, "utf8"));
20692
21930
  } catch {
20693
21931
  return null;
20694
21932
  }
20695
21933
  }
20696
21934
  async function hasSize(file, bytes) {
20697
21935
  try {
20698
- return (await fs29.stat(file)).size === bytes;
21936
+ return (await fs32.stat(file)).size === bytes;
20699
21937
  } catch {
20700
21938
  return false;
20701
21939
  }
20702
21940
  }
20703
21941
  async function exists3(p) {
20704
21942
  try {
20705
- await fs29.access(p);
21943
+ await fs32.access(p);
20706
21944
  return true;
20707
21945
  } catch {
20708
21946
  return false;
@@ -20903,8 +22141,8 @@ var CONTROLLER_FILE_SETS = {
20903
22141
  ]
20904
22142
  }
20905
22143
  };
20906
- var CODE_DEST = path31.join("src", "controllers");
20907
- var ASSETS_DEST = path31.join("public", "assets");
22144
+ var CODE_DEST = path33.join("src", "controllers");
22145
+ var ASSETS_DEST = path33.join("public", "assets");
20908
22146
  async function runController(opts) {
20909
22147
  const log = createLogger({ quiet: opts.quiet });
20910
22148
  if (opts.kind?.trim() === "anims") {
@@ -20921,31 +22159,31 @@ async function runController(opts) {
20921
22159
  process.exitCode = 1;
20922
22160
  return;
20923
22161
  }
20924
- const srcDir = path31.join(getTemplatesDir(), "controllers");
22162
+ const srcDir = path33.join(getTemplatesDir(), "controllers");
20925
22163
  const root = opts.cwd ?? process.cwd();
20926
22164
  const set = CONTROLLER_FILE_SETS[kind];
20927
22165
  log.plain(c.bold(`genex controller ${kind}`));
20928
22166
  log.plain("");
20929
- log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path31.sep)}`);
22167
+ log.step(`Installing the ${kind} controller into ${c.cyan(CODE_DEST + path33.sep)}`);
20930
22168
  const plan = [
20931
- ...set.code.map((rel) => ({ from: rel, rel: path31.join(CODE_DEST, rel) })),
22169
+ ...set.code.map((rel) => ({ from: rel, rel: path33.join(CODE_DEST, rel) })),
20932
22170
  ...set.assets.map((rel) => ({
20933
22171
  from: rel,
20934
- rel: path31.join(ASSETS_DEST, path31.basename(rel))
22172
+ rel: path33.join(ASSETS_DEST, path33.basename(rel))
20935
22173
  }))
20936
22174
  ];
20937
22175
  let copied = 0;
20938
22176
  let skipped = 0;
20939
22177
  try {
20940
22178
  for (const file of plan) {
20941
- const dest = path31.join(root, file.rel);
22179
+ const dest = path33.join(root, file.rel);
20942
22180
  if (!opts.force && await exists4(dest)) {
20943
22181
  skipped++;
20944
22182
  log.dim(` skipped ${file.rel} (exists \u2014 use --force to overwrite)`);
20945
22183
  continue;
20946
22184
  }
20947
- await fs30.mkdir(path31.dirname(dest), { recursive: true });
20948
- await fs30.copyFile(path31.join(srcDir, file.from), dest);
22185
+ await fs33.mkdir(path33.dirname(dest), { recursive: true });
22186
+ await fs33.copyFile(path33.join(srcDir, file.from), dest);
20949
22187
  copied++;
20950
22188
  log.dim(` ${file.rel}`);
20951
22189
  }
@@ -20998,7 +22236,7 @@ async function runController(opts) {
20998
22236
  for (const line of set.sketch) {
20999
22237
  log.dim(` ${line}`);
21000
22238
  }
21001
- if (kind === "character" && !await exists4(path31.join(root, ASSETS_DEST, "meshy-character.json"))) {
22239
+ if (kind === "character" && !await exists4(path33.join(root, ASSETS_DEST, "meshy-character.json"))) {
21002
22240
  log.plain("");
21003
22241
  log.plain(
21004
22242
  ` ${stepOffset + 3}. This game has no generated character yet \u2014 ${c.cyan(
@@ -21030,9 +22268,9 @@ async function installMeshyCharacterManifest(args) {
21030
22268
  throw new Error("The API returned an invalid Meshy character manifest.");
21031
22269
  }
21032
22270
  assertCompleteMeshyControllerPack(manifest);
21033
- const destination = path31.join(args.root, ASSETS_DEST, "meshy-character.json");
21034
- await fs30.mkdir(path31.dirname(destination), { recursive: true });
21035
- await fs30.writeFile(
22271
+ const destination = path33.join(args.root, ASSETS_DEST, "meshy-character.json");
22272
+ await fs33.mkdir(path33.dirname(destination), { recursive: true });
22273
+ await fs33.writeFile(
21036
22274
  destination,
21037
22275
  `${JSON.stringify(manifest, null, 2)}
21038
22276
  `
@@ -21177,14 +22415,14 @@ function assertCompleteMeshyControllerPack(manifest) {
21177
22415
  }
21178
22416
  async function installFallbackAvatar(args) {
21179
22417
  const { root, srcDir, log } = args;
21180
- const dest = path31.join(root, ASSETS_DEST, "avatar.vrm");
21181
- await fs30.mkdir(path31.dirname(dest), { recursive: true });
21182
- await fs30.copyFile(path31.join(srcDir, "assets", "default-avatar.vrm"), dest);
22418
+ const dest = path33.join(root, ASSETS_DEST, "avatar.vrm");
22419
+ await fs33.mkdir(path33.dirname(dest), { recursive: true });
22420
+ await fs33.copyFile(path33.join(srcDir, "assets", "default-avatar.vrm"), dest);
21183
22421
  log.dim(" public/assets/avatar.vrm (fallback avatar \u2014 bundled CC0 default)");
21184
22422
  }
21185
22423
  async function exists4(p) {
21186
22424
  try {
21187
- await fs30.access(p);
22425
+ await fs33.access(p);
21188
22426
  return true;
21189
22427
  } catch {
21190
22428
  return false;
@@ -21192,8 +22430,8 @@ async function exists4(p) {
21192
22430
  }
21193
22431
 
21194
22432
  // src/commands/character.ts
21195
- import fs31 from "fs/promises";
21196
- import path32 from "path";
22433
+ import fs34 from "fs/promises";
22434
+ import path34 from "path";
21197
22435
  function exactAnimation(selector) {
21198
22436
  const trimmed = selector.trim();
21199
22437
  if (/^-?\d+$/.test(trimmed)) return animationById(Number(trimmed));
@@ -21278,7 +22516,7 @@ function showAmbiguity(selector, candidates, log, json = false) {
21278
22516
  }
21279
22517
  process.exitCode = 1;
21280
22518
  }
21281
- var INSTALLED_MANIFEST = path32.join("public", "assets", "meshy-character.json");
22519
+ var INSTALLED_MANIFEST = path34.join("public", "assets", "meshy-character.json");
21282
22520
  async function resolveAdoptTarget(selector) {
21283
22521
  const trimmed = selector?.trim();
21284
22522
  if (trimmed && !trimmed.endsWith(".json")) {
@@ -21287,7 +22525,7 @@ async function resolveAdoptTarget(selector) {
21287
22525
  const file = trimmed ?? INSTALLED_MANIFEST;
21288
22526
  let raw;
21289
22527
  try {
21290
- raw = await fs31.readFile(file, "utf8");
22528
+ raw = await fs34.readFile(file, "utf8");
21291
22529
  } catch {
21292
22530
  return {
21293
22531
  ok: false,
@@ -21412,7 +22650,7 @@ async function runCharacterImport(opts) {
21412
22650
  opts,
21413
22651
  ctx,
21414
22652
  kind: "character",
21415
- prompt: `Import ${path32.basename(filePath)} as a rigged character`,
22653
+ prompt: `Import ${path34.basename(filePath)} as a rigged character`,
21416
22654
  createPath: "/api/characters/import",
21417
22655
  body,
21418
22656
  quote: price,
@@ -21957,22 +23195,22 @@ async function context2(opts) {
21957
23195
  if (!token) return null;
21958
23196
  return { token, apiUrl };
21959
23197
  }
21960
- async function readVideo(path47, log) {
23198
+ async function readVideo(path49, log) {
21961
23199
  let bytes;
21962
23200
  try {
21963
- bytes = await readFile(path47);
23201
+ bytes = await readFile(path49);
21964
23202
  } catch {
21965
- log.error(`Can't read ${path47}.`);
23203
+ log.error(`Can't read ${path49}.`);
21966
23204
  return null;
21967
23205
  }
21968
23206
  if (bytes.byteLength > MAX_VIDEO_BYTES) {
21969
- log.error(`${basename(path47)} is ${(bytes.byteLength / 1e6).toFixed(0)} MB \u2014 the limit is ${MAX_VIDEO_BYTES / 1e6} MB.`);
23207
+ log.error(`${basename(path49)} is ${(bytes.byteLength / 1e6).toFixed(0)} MB \u2014 the limit is ${MAX_VIDEO_BYTES / 1e6} MB.`);
21970
23208
  return null;
21971
23209
  }
21972
23210
  return bytes;
21973
23211
  }
21974
- async function uploadVideo(apiUrl, token, characterId, path47, bytes, log) {
21975
- const contentType = /\.mov$/i.test(path47) ? "video/quicktime" : "video/mp4";
23212
+ async function uploadVideo(apiUrl, token, characterId, path49, bytes, log) {
23213
+ const contentType = /\.mov$/i.test(path49) ? "video/quicktime" : "video/mp4";
21976
23214
  const minted = await apiFetch(
21977
23215
  `${apiUrl}/api/characters/${encodeURIComponent(characterId)}/motions/video-url`,
21978
23216
  {
@@ -21987,7 +23225,7 @@ async function uploadVideo(apiUrl, token, characterId, path47, bytes, log) {
21987
23225
  return null;
21988
23226
  }
21989
23227
  const { uploadUrl, videoUrl } = await minted.json();
21990
- log.dim(` uploading ${basename(path47)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
23228
+ log.dim(` uploading ${basename(path49)} (${(bytes.byteLength / 1e6).toFixed(1)} MB)\u2026`);
21991
23229
  const put = await fetch(uploadUrl, {
21992
23230
  method: "PUT",
21993
23231
  headers: { "Content-Type": contentType, "Content-Length": String(bytes.byteLength) },
@@ -22319,11 +23557,11 @@ function rank(items, query) {
22319
23557
  }
22320
23558
 
22321
23559
  // src/commands/motion.ts
22322
- import fs32 from "fs/promises";
22323
- import path33 from "path";
23560
+ import fs35 from "fs/promises";
23561
+ import path35 from "path";
22324
23562
 
22325
23563
  // src/lib/motion/npz.ts
22326
- import zlib from "zlib";
23564
+ import zlib2 from "zlib";
22327
23565
  function decodeUcs4(b) {
22328
23566
  let out = "";
22329
23567
  for (let i = 0; i + 4 <= b.length; i += 4) {
@@ -22374,7 +23612,7 @@ function readNpz(buf) {
22374
23612
  const lExtraLen = buf.readUInt16LE(lho + 28);
22375
23613
  const dataStart = lho + 30 + lNameLen + lExtraLen;
22376
23614
  const raw = buf.subarray(dataStart, dataStart + csize);
22377
- const bytes = method === 8 ? zlib.inflateRawSync(raw) : Buffer.from(raw);
23615
+ const bytes = method === 8 ? zlib2.inflateRawSync(raw) : Buffer.from(raw);
22378
23616
  if (method !== 0 && method !== 8) throw new Error(`unsupported ZIP method ${method} for ${name}`);
22379
23617
  out[name.replace(/\.npy$/, "")] = readNpy(bytes);
22380
23618
  off += 46 + nameLen + extraLen + cmtLen;
@@ -23571,7 +24809,7 @@ async function motionGen(opts, log) {
23571
24809
  }
23572
24810
  if (opts.constraintsPath !== void 0) {
23573
24811
  try {
23574
- const raw = await fs32.readFile(opts.constraintsPath, "utf8");
24812
+ const raw = await fs35.readFile(opts.constraintsPath, "utf8");
23575
24813
  generationOptions.constraints = JSON.parse(raw);
23576
24814
  } catch {
23577
24815
  log.error(`Couldn't read the --constraints JSON at ${opts.constraintsPath}.`);
@@ -23595,10 +24833,10 @@ async function motionGen(opts, log) {
23595
24833
  async function expandTakes(selectors) {
23596
24834
  const out = [];
23597
24835
  for (const sel of selectors) {
23598
- const st = await fs32.stat(sel).catch(() => null);
24836
+ const st = await fs35.stat(sel).catch(() => null);
23599
24837
  if (st?.isDirectory()) {
23600
- const names2 = await fs32.readdir(sel);
23601
- for (const n of names2.sort()) if (n.endsWith(".npz")) out.push(path33.join(sel, n));
24838
+ const names2 = await fs35.readdir(sel);
24839
+ for (const n of names2.sort()) if (n.endsWith(".npz")) out.push(path35.join(sel, n));
23602
24840
  } else if (st?.isFile()) {
23603
24841
  out.push(sel);
23604
24842
  } else {
@@ -23633,7 +24871,7 @@ async function motionVerify(opts, log) {
23633
24871
  let gates = DEFAULT_GATES;
23634
24872
  if (opts.gatesPath) {
23635
24873
  try {
23636
- gates = mergeGates(DEFAULT_GATES, JSON.parse(await fs32.readFile(opts.gatesPath, "utf8")));
24874
+ gates = mergeGates(DEFAULT_GATES, JSON.parse(await fs35.readFile(opts.gatesPath, "utf8")));
23637
24875
  } catch {
23638
24876
  log.error(`Couldn't read the --gates JSON at ${opts.gatesPath}.`);
23639
24877
  process.exitCode = 1;
@@ -23655,9 +24893,9 @@ async function motionVerify(opts, log) {
23655
24893
  }
23656
24894
  const reports = [];
23657
24895
  for (const file of files) {
23658
- const stem = path33.basename(file).replace(/\.npz$/, "");
24896
+ const stem = path35.basename(file).replace(/\.npz$/, "");
23659
24897
  try {
23660
- reports.push(analyzeTake(stem, await fs32.readFile(file), gates));
24898
+ reports.push(analyzeTake(stem, await fs35.readFile(file), gates));
23661
24899
  } catch (err) {
23662
24900
  reports.push({
23663
24901
  take: stem,
@@ -23695,7 +24933,7 @@ async function motionCompile(opts, log) {
23695
24933
  let cfg = DEFAULT_MOTION_CONFIG;
23696
24934
  if (opts.configPath) {
23697
24935
  try {
23698
- const patch = JSON.parse(await fs32.readFile(opts.configPath, "utf8"));
24936
+ const patch = JSON.parse(await fs35.readFile(opts.configPath, "utf8"));
23699
24937
  cfg = { ...DEFAULT_MOTION_CONFIG, ...patch, idleLoop: { ...DEFAULT_MOTION_CONFIG.idleLoop, ...patch.idleLoop } };
23700
24938
  } catch {
23701
24939
  log.error(`Couldn't read the --config JSON at ${opts.configPath}.`);
@@ -23713,16 +24951,16 @@ async function motionCompile(opts, log) {
23713
24951
  }
23714
24952
  const inputs = [];
23715
24953
  for (const file of files) {
23716
- const stem = path33.basename(file).replace(/\.npz$/, "");
24954
+ const stem = path35.basename(file).replace(/\.npz$/, "");
23717
24955
  try {
23718
- inputs.push({ stem, take: loadTake(await fs32.readFile(file)) });
24956
+ inputs.push({ stem, take: loadTake(await fs35.readFile(file)) });
23719
24957
  } catch (err) {
23720
24958
  log.error(`${stem}: ${err instanceof Error ? err.message : String(err)}`);
23721
24959
  process.exitCode = 1;
23722
24960
  return;
23723
24961
  }
23724
24962
  }
23725
- const setName = opts.set ?? path33.basename(opts.out).replace(/\.json$/, "");
24963
+ const setName = opts.set ?? path35.basename(opts.out).replace(/\.json$/, "");
23726
24964
  let result;
23727
24965
  try {
23728
24966
  result = compileSet(inputs, setName, cfg);
@@ -23737,9 +24975,9 @@ async function motionCompile(opts, log) {
23737
24975
  process.exitCode = 1;
23738
24976
  return;
23739
24977
  }
23740
- await fs32.mkdir(path33.dirname(path33.resolve(opts.out)), { recursive: true });
24978
+ await fs35.mkdir(path35.dirname(path35.resolve(opts.out)), { recursive: true });
23741
24979
  const json = JSON.stringify(result.data);
23742
- await fs32.writeFile(opts.out, json);
24980
+ await fs35.writeFile(opts.out, json);
23743
24981
  if (opts.json) {
23744
24982
  writeJson({ out: opts.out, set: setName, tracks: Object.keys(result.data.gaits), bytes: json.length });
23745
24983
  return;
@@ -23757,9 +24995,9 @@ var MOTION_RUNTIME_FILES = [
23757
24995
  var MOTION_PRESETS = {
23758
24996
  rifle: ["sets/rifle.json", "sets/jumps.json"]
23759
24997
  };
23760
- var MOTION_DEST = path33.join("src", "motion");
24998
+ var MOTION_DEST = path35.join("src", "motion");
23761
24999
  async function motionInstall(opts, log) {
23762
- const srcDir = path33.join(getTemplatesDir(), "motion");
25000
+ const srcDir = path35.join(getTemplatesDir(), "motion");
23763
25001
  const root = opts.cwd ?? process.cwd();
23764
25002
  const preset = opts.set;
23765
25003
  if (preset !== void 0 && !MOTION_PRESETS[preset]) {
@@ -23770,21 +25008,21 @@ async function motionInstall(opts, log) {
23770
25008
  const files = [...MOTION_RUNTIME_FILES, ...preset ? MOTION_PRESETS[preset] : []];
23771
25009
  log.plain(c.bold(`genex motion install${preset ? ` --set ${preset}` : ""}`));
23772
25010
  log.plain("");
23773
- log.step(`Vendoring the motion runtime into ${c.cyan(MOTION_DEST + path33.sep)}`);
25011
+ log.step(`Vendoring the motion runtime into ${c.cyan(MOTION_DEST + path35.sep)}`);
23774
25012
  let copied = 0, skipped = 0;
23775
25013
  try {
23776
25014
  for (const rel of files) {
23777
- const dest = path33.join(root, MOTION_DEST, rel);
23778
- const exists5 = await fs32.access(dest).then(() => true, () => false);
25015
+ const dest = path35.join(root, MOTION_DEST, rel);
25016
+ const exists5 = await fs35.access(dest).then(() => true, () => false);
23779
25017
  if (!opts.force && exists5) {
23780
25018
  skipped++;
23781
- log.dim(` skipped ${path33.join(MOTION_DEST, rel)} (exists \u2014 use --force to overwrite)`);
25019
+ log.dim(` skipped ${path35.join(MOTION_DEST, rel)} (exists \u2014 use --force to overwrite)`);
23782
25020
  continue;
23783
25021
  }
23784
- await fs32.mkdir(path33.dirname(dest), { recursive: true });
23785
- await fs32.copyFile(path33.join(srcDir, rel), dest);
25022
+ await fs35.mkdir(path35.dirname(dest), { recursive: true });
25023
+ await fs35.copyFile(path35.join(srcDir, rel), dest);
23786
25024
  copied++;
23787
- log.dim(` ${path33.join(MOTION_DEST, rel)}`);
25025
+ log.dim(` ${path35.join(MOTION_DEST, rel)}`);
23788
25026
  }
23789
25027
  } catch (err) {
23790
25028
  log.error(`Copy failed: ${String(err)}`);
@@ -23825,7 +25063,7 @@ async function motionConstraints(opts, log) {
23825
25063
  }
23826
25064
  const doc = directionConstraint(dir, speed, duration);
23827
25065
  const out = opts.out ?? "constraints.json";
23828
- await fs32.writeFile(out, JSON.stringify(doc));
25066
+ await fs35.writeFile(out, JSON.stringify(doc));
23829
25067
  if (opts.json) {
23830
25068
  writeJson({ out, dir: dirName, speed, duration, waypoints: doc[0].frame_indices.length });
23831
25069
  return;
@@ -23862,14 +25100,14 @@ async function runMotion(opts) {
23862
25100
  }
23863
25101
 
23864
25102
  // src/commands/blender.ts
23865
- import fs33 from "fs/promises";
23866
- import path34 from "path";
23867
- var SUBS2 = ["demo", "exec", "snap", "scene", "import", "export", "reset", "mcp", "serve", "seat", "release"];
25103
+ import fs36 from "fs/promises";
25104
+ import path36 from "path";
25105
+ var SUBS3 = ["demo", "exec", "snap", "scene", "import", "export", "reset", "mcp", "serve", "seat", "release"];
23868
25106
  var DEFAULT_OUT_DIR = "assets/blender";
23869
25107
  async function writeB64(dir, name, b64) {
23870
- await fs33.mkdir(dir, { recursive: true });
23871
- const p = path34.join(dir, name);
23872
- await fs33.writeFile(p, Buffer.from(b64, "base64"));
25108
+ await fs36.mkdir(dir, { recursive: true });
25109
+ const p = path36.join(dir, name);
25110
+ await fs36.writeFile(p, Buffer.from(b64, "base64"));
23873
25111
  return p;
23874
25112
  }
23875
25113
  function reportScene(log, s) {
@@ -23881,12 +25119,12 @@ function reportScene(log, s) {
23881
25119
  async function runBlender(opts) {
23882
25120
  const log = createLogger();
23883
25121
  const sub = opts.sub;
23884
- if (!sub || !SUBS2.includes(sub)) {
23885
- log.error(`Usage: genex blender <${SUBS2.join("|")}>`);
25122
+ if (!sub || !SUBS3.includes(sub)) {
25123
+ log.error(`Usage: genex blender <${SUBS3.join("|")}>`);
23886
25124
  return 1;
23887
25125
  }
23888
25126
  if (sub === "serve") {
23889
- const { serveLocalBlender } = await import("./blender-serve-66OHC4ML.js");
25127
+ const { serveLocalBlender } = await import("./blender-serve-EH2MOW3U.js");
23890
25128
  const port = Number(process.env.GENEX_BLENDER_PORT ?? 8088);
23891
25129
  log.step(`Starting a local Blender service on port ${port}`);
23892
25130
  log.plain(
@@ -23895,7 +25133,7 @@ async function runBlender(opts) {
23895
25133
  return serveLocalBlender({ port, log });
23896
25134
  }
23897
25135
  if (sub === "mcp") {
23898
- const { runBlenderMcp } = await import("./blender-mcp-QVEGHE5L.js");
25136
+ const { runBlenderMcp } = await import("./blender-mcp-WSURPZET.js");
23899
25137
  return runBlenderMcp();
23900
25138
  }
23901
25139
  if (sub === "seat") {
@@ -23953,7 +25191,7 @@ async function runBlender(opts) {
23953
25191
  log.plain(rest.join("\n"));
23954
25192
  return 1;
23955
25193
  }
23956
- const outDir = path34.resolve(opts.outDir ?? DEFAULT_OUT_DIR);
25194
+ const outDir = path36.resolve(opts.outDir ?? DEFAULT_OUT_DIR);
23957
25195
  const mode = opts.mode;
23958
25196
  if (mode !== void 0 && !isRenderMode(mode)) {
23959
25197
  log.error(`Unknown --mode ${mode}. Use one of: ${RENDER_MODES.join(", ")}.`);
@@ -23993,14 +25231,14 @@ async function runBlender(opts) {
23993
25231
  return 0;
23994
25232
  }
23995
25233
  case "export": {
23996
- const target = opts.out ?? path34.join(outDir, "scene.glb");
25234
+ const target = opts.out ?? path36.join(outDir, "scene.glb");
23997
25235
  const r = await blenderCall(base, "/export", { path: "/tmp/genex-export.glb" });
23998
25236
  if (!r.glbBase64) {
23999
25237
  log.error(`/export answered with no GLB bytes${r.uploaded ? " (it was uploaded, not inlined)" : ""}`);
24000
25238
  return 1;
24001
25239
  }
24002
- await fs33.mkdir(path34.dirname(target), { recursive: true });
24003
- await fs33.writeFile(target, Buffer.from(r.glbBase64, "base64"));
25240
+ await fs36.mkdir(path36.dirname(target), { recursive: true });
25241
+ await fs36.writeFile(target, Buffer.from(r.glbBase64, "base64"));
24004
25242
  log.success(`Exported ${r.bytes ?? 0} bytes`);
24005
25243
  log.plain(` ${c.cyan(target)}`);
24006
25244
  return 0;
@@ -24033,12 +25271,12 @@ async function runBlender(opts) {
24033
25271
  return 1;
24034
25272
  }
24035
25273
  try {
24036
- script = await fs33.readFile(opts.input, "utf8");
25274
+ script = await fs36.readFile(opts.input, "utf8");
24037
25275
  } catch {
24038
25276
  log.error(`Can't read ${opts.input}`);
24039
25277
  return 1;
24040
25278
  }
24041
- label = path34.basename(opts.input);
25279
+ label = path36.basename(opts.input);
24042
25280
  }
24043
25281
  const r = await blenderCall(base, "/exec", { script, ...mode ? { mode } : {}, sheet: { formats: SHEET_FORMATS } });
24044
25282
  if (r.stdout?.trim()) log.plain(r.stdout.trimEnd());
@@ -24157,9 +25395,9 @@ print(f"castle: {n} objects")
24157
25395
  `;
24158
25396
 
24159
25397
  // src/commands/asset-new.ts
24160
- import fs34 from "fs";
25398
+ import fs37 from "fs";
24161
25399
  import fsp from "fs/promises";
24162
- import path35 from "path";
25400
+ import path37 from "path";
24163
25401
  import { pathToFileURL } from "url";
24164
25402
  var EXTRA_FILES = [
24165
25403
  "genex-asset.example.json",
@@ -24253,7 +25491,7 @@ Every milestone ends with \`npm run verify\`, then \`npx genex preview --no-buil
24253
25491
  }
24254
25492
  async function runAssetNew(options) {
24255
25493
  const log = createLogger();
24256
- const cwd = options.dir ? path35.resolve(options.dir) : process.cwd();
25494
+ const cwd = options.dir ? path37.resolve(options.dir) : process.cwd();
24257
25495
  const slug = options.assetSlug;
24258
25496
  if (!slug) {
24259
25497
  log.error('Usage: genex asset new <slug> [--title "<Name>"] [--dims WxDxH] [--tri-band LOW-HIGH] [--holder "<your name>"] [--out <dir>]');
@@ -24263,14 +25501,14 @@ async function runAssetNew(options) {
24263
25501
  log.error(`The slug must be kebab-case (letters, digits, dashes), got: ${slug}`);
24264
25502
  return 1;
24265
25503
  }
24266
- const templateDir = path35.join(getTemplatesDir(), "asset-viewer");
24267
- if (!fs34.existsSync(templateDir)) {
25504
+ const templateDir = path37.join(getTemplatesDir(), "asset-viewer");
25505
+ if (!fs37.existsSync(templateDir)) {
24268
25506
  log.error(`Vendored asset-viewer template not found at ${templateDir} \u2014 reinstall the CLI.`);
24269
25507
  return 1;
24270
25508
  }
24271
- const manifestTools = await import(pathToFileURL(path35.join(templateDir, "tools", "emit-manifest.mjs")).href);
25509
+ const manifestTools = await import(pathToFileURL(path37.join(templateDir, "tools", "emit-manifest.mjs")).href);
24272
25510
  const { SHARED_FILES, PARITY_FILENAME, hashSharedFiles, pascalCase } = manifestTools;
24273
- const lockPath = path35.join(templateDir, "shared-files.sha256.json");
25511
+ const lockPath = path37.join(templateDir, "shared-files.sha256.json");
24274
25512
  const lock = JSON.parse(await fsp.readFile(lockPath, "utf8"));
24275
25513
  const actual = hashSharedFiles(templateDir);
24276
25514
  const drifted = SHARED_FILES.filter((rel) => lock.files[rel] !== actual[rel]);
@@ -24283,8 +25521,8 @@ async function runAssetNew(options) {
24283
25521
  const triBand = parseBand(options.triBand ?? "500-8000");
24284
25522
  const holder = options.holder?.trim() || HOLDER_PLACEHOLDER;
24285
25523
  const year = (/* @__PURE__ */ new Date()).getUTCFullYear();
24286
- const outDir = path35.resolve(cwd, options.out ?? slug);
24287
- if (fs34.existsSync(outDir) && fs34.readdirSync(outDir).length > 0 && !options.force) {
25524
+ const outDir = path37.resolve(cwd, options.out ?? slug);
25525
+ if (fs37.existsSync(outDir) && fs37.readdirSync(outDir).length > 0 && !options.force) {
24288
25526
  log.error(`${outDir} already holds files. Nothing was touched \u2014 pass --force to write into it anyway.`);
24289
25527
  return 1;
24290
25528
  }
@@ -24312,24 +25550,24 @@ async function runAssetNew(options) {
24312
25550
  };
24313
25551
  await fsp.mkdir(outDir, { recursive: true });
24314
25552
  for (const rel of [...SHARED_FILES, ...EXTRA_FILES]) {
24315
- const to = path35.join(outDir, rel);
24316
- await fsp.mkdir(path35.dirname(to), { recursive: true });
24317
- await fsp.copyFile(path35.join(templateDir, rel), to);
25553
+ const to = path37.join(outDir, rel);
25554
+ await fsp.mkdir(path37.dirname(to), { recursive: true });
25555
+ await fsp.copyFile(path37.join(templateDir, rel), to);
24318
25556
  }
24319
- const pkg = fillTemplate(await fsp.readFile(path35.join(templateDir, "package.json"), "utf8"), {
25557
+ const pkg = fillTemplate(await fsp.readFile(path37.join(templateDir, "package.json"), "utf8"), {
24320
25558
  slug,
24321
25559
  name,
24322
25560
  version
24323
25561
  });
24324
- await fsp.writeFile(path35.join(outDir, "package.json"), pkg, "utf8");
24325
- await fsp.writeFile(path35.join(outDir, "asset.config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
24326
- await fsp.writeFile(path35.join(outDir, ".gitignore"), GITIGNORE, "utf8");
25562
+ await fsp.writeFile(path37.join(outDir, "package.json"), pkg, "utf8");
25563
+ await fsp.writeFile(path37.join(outDir, "asset.config.json"), JSON.stringify(config, null, 2) + "\n", "utf8");
25564
+ await fsp.writeFile(path37.join(outDir, ".gitignore"), GITIGNORE, "utf8");
24327
25565
  await fsp.writeFile(
24328
- path35.join(outDir, "DESIGN.md"),
25566
+ path37.join(outDir, "DESIGN.md"),
24329
25567
  designDoc({ name, slug, sizeMeters, triBand, holder }),
24330
25568
  "utf8"
24331
25569
  );
24332
- const placeholder = await fsp.readFile(path35.join(templateDir, "src", "asset", "PLACEHOLDER.ts"), "utf8");
25570
+ const placeholder = await fsp.readFile(path37.join(templateDir, "src", "asset", "PLACEHOLDER.ts"), "utf8");
24333
25571
  const seeded = seedAssetSource(placeholder, {
24334
25572
  slug,
24335
25573
  name,
@@ -24340,8 +25578,8 @@ async function runAssetNew(options) {
24340
25578
  pascalCase
24341
25579
  });
24342
25580
  const assetFilename = `${slug.replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase())}.ts`;
24343
- await fsp.mkdir(path35.join(outDir, "src", "asset"), { recursive: true });
24344
- await fsp.writeFile(path35.join(outDir, "src", "asset", assetFilename), seeded, "utf8");
25581
+ await fsp.mkdir(path37.join(outDir, "src", "asset"), { recursive: true });
25582
+ await fsp.writeFile(path37.join(outDir, "src", "asset", assetFilename), seeded, "utf8");
24345
25583
  const copied = hashSharedFiles(outDir);
24346
25584
  const mismatched = SHARED_FILES.filter((rel) => copied[rel] !== lock.files[rel]);
24347
25585
  if (mismatched.length) {
@@ -24349,7 +25587,7 @@ async function runAssetNew(options) {
24349
25587
  return 1;
24350
25588
  }
24351
25589
  await fsp.writeFile(
24352
- path35.join(outDir, PARITY_FILENAME),
25590
+ path37.join(outDir, PARITY_FILENAME),
24353
25591
  JSON.stringify(
24354
25592
  {
24355
25593
  note: "Recorded by `genex asset new`. Gate G13 recomputes these; do not hand-edit the viewer.",
@@ -24393,17 +25631,22 @@ async function runAssetNew(options) {
24393
25631
  }
24394
25632
 
24395
25633
  // src/commands/tools.ts
24396
- import path38 from "path";
25634
+ import path40 from "path";
24397
25635
 
24398
25636
  // src/commands/doctor.ts
24399
- import path37 from "path";
25637
+ import path39 from "path";
24400
25638
 
24401
25639
  // src/commands/llm.ts
24402
- import fs35 from "fs/promises";
24403
- import path36 from "path";
25640
+ import fs38 from "fs/promises";
25641
+ import path38 from "path";
24404
25642
  import { randomUUID as randomUUID2 } from "crypto";
24405
- var SUBS3 = ["models", "bench", "price", "status", "cancel"];
24406
- var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN coin \u2014 your wallet, not a player's, and one this build's asset allowance cannot see. Re-run with --max-coins <n> --user-approved only after the person at the keyboard agreed to the number.";
25643
+ var SUBS4 = ["models", "bench", "price", "status", "cancel"];
25644
+ var LLM_BENCH_APPROVAL_REQUIRED = "STOP: `genex llm bench` runs the real model and spends YOUR OWN credits \u2014 your balance, not a player's, and a spend this build's asset allowance does not count. Re-run with --max-credits <n> --user-approved only after the person at the keyboard agreed to the number.";
25645
+ var LLM_MAX_COINS_DEPRECATED = "--max-coins is deprecated: it is read as --max-credits, the same number \u2014 a bench spends credits now.";
25646
+ function benchBalanceShortLine(spendable, maxCredits) {
25647
+ return `Your spendable credits (${spendable}) cannot cover one attempt at --max-credits ${maxCredits} \u2014 nothing was sent, nothing was spent. Add credits, or re-run with a lower --max-credits once the person at the keyboard agrees to it.`;
25648
+ }
25649
+ var LLM_BENCH_UNVERIFIED_LINE = "Credits pay only for a verified email, and this account's is not verified yet (credits_unverified) \u2014 nothing was sent, nothing was spent. Verify it, then re-run.";
24407
25650
  var LLM_LANE_OFF_LINE = "The runtime LLM lane is off on this stand \u2014 in-game generate() answers 404 here, and nothing can be benchmarked.";
24408
25651
  var LLM_TOOLS_CONVERT_HINT = [
24409
25652
  "In-game model calls need a hosted game. This folder is a Genex Tools workspace, so nothing here",
@@ -24416,7 +25659,7 @@ function serverSlotCounts(body) {
24416
25659
  return { held: body.slotsHeld, limit: body.slotLimit };
24417
25660
  }
24418
25661
  var PENDING_SLOT_RELEASE_MINUTES = 10;
24419
- var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so its coin stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
25662
+ var LLM_STATUS_LEGEND = `"awaiting bill" = the call has stopped but the provider's bill is not final, so what it reserved stays held until the bill resolves; such a row stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`;
24420
25663
  var LLM_STATUS_NO_COUNT_LINE = "This stand does not report how many slots are held across your projects, so no count is shown.";
24421
25664
  var LLM_CANCEL_AWAITING_BILL = "this call already stopped; its bill is awaiting the provider";
24422
25665
  var LLM_CANCEL_ALREADY_FINAL = "this call is already final";
@@ -24430,11 +25673,33 @@ var DEFAULT_SAMPLE_TIMEOUT_SEC = 180;
24430
25673
  function parseProviderKey(value) {
24431
25674
  return value === "ok" || value === "rejected" || value === "unknown" ? value : null;
24432
25675
  }
25676
+ function bpsOrNull(value) {
25677
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
25678
+ }
25679
+ function standingCreditGrantOrNull(value) {
25680
+ if (!value || typeof value !== "object") return null;
25681
+ const v = value;
25682
+ const whole = (x) => typeof x === "number" && Number.isSafeInteger(x) && x >= 0;
25683
+ if (!whole(v.minCredits) || !whole(v.maxCredits) || !whole(v.stepCredits) || !whole(v.defaultCredits)) return null;
25684
+ return {
25685
+ minCredits: v.minCredits,
25686
+ maxCredits: v.maxCredits,
25687
+ stepCredits: v.stepCredits,
25688
+ defaultCredits: v.defaultCredits,
25689
+ ...whole(v.minUsdCents) ? { minUsdCents: v.minUsdCents } : {},
25690
+ ...whole(v.maxUsdCents) ? { maxUsdCents: v.maxUsdCents } : {},
25691
+ ...whole(v.stepUsdCents) ? { stepUsdCents: v.stepUsdCents } : {}
25692
+ };
25693
+ }
24433
25694
  async function readRuntimeLane(apiUrl, token) {
24434
25695
  const empty = (state) => ({
24435
25696
  state,
24436
25697
  models: null,
24437
25698
  recommendedDeclaredHeadroomBps: null,
25699
+ currency: null,
25700
+ feeBps: null,
25701
+ usdCentsPerCredit: null,
25702
+ standingCreditGrant: null,
24438
25703
  externalProviders: null,
24439
25704
  total: null,
24440
25705
  featuredCount: null,
@@ -24453,7 +25718,11 @@ async function readRuntimeLane(apiUrl, token) {
24453
25718
  return {
24454
25719
  state: "live",
24455
25720
  models: body.models,
24456
- recommendedDeclaredHeadroomBps: typeof body.recommendedDeclaredHeadroomBps === "number" && Number.isFinite(body.recommendedDeclaredHeadroomBps) ? body.recommendedDeclaredHeadroomBps : null,
25721
+ recommendedDeclaredHeadroomBps: bpsOrNull(body.recommendedDeclaredHeadroomBps),
25722
+ currency: body.currency === "credits" || body.currency === "coins" ? body.currency : null,
25723
+ feeBps: bpsOrNull(body.feeBps),
25724
+ usdCentsPerCredit: typeof body.usdCentsPerCredit === "number" && Number.isSafeInteger(body.usdCentsPerCredit) && body.usdCentsPerCredit >= 1 ? body.usdCentsPerCredit : null,
25725
+ standingCreditGrant: standingCreditGrantOrNull(body.standingCreditGrant),
24457
25726
  externalProviders: Array.isArray(body.externalProviders) ? body.externalProviders : null,
24458
25727
  total: typeof body.total === "number" && Number.isFinite(body.total) ? body.total : null,
24459
25728
  featuredCount: typeof body.featuredCount === "number" && Number.isFinite(body.featuredCount) ? body.featuredCount : null,
@@ -24469,18 +25738,45 @@ function percentile2(values, p) {
24469
25738
  const rank2 = Math.ceil(p / 100 * sorted.length);
24470
25739
  return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
24471
25740
  }
24472
- function recommendEstimateCoins(p95, headroomBps) {
24473
- return Math.max(1, Math.ceil(p95 * (1e4 + headroomBps) / 1e4));
25741
+ function percentilePicos(values, p) {
25742
+ if (values.length === 0) return null;
25743
+ const sorted = [...values].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
25744
+ const rank2 = Math.ceil(p / 100 * sorted.length);
25745
+ return sorted[Math.min(sorted.length - 1, Math.max(0, rank2 - 1))];
24474
25746
  }
24475
- function recommendPerCallMaxCoins(p95, max, headroomBps) {
24476
- return Math.max(recommendEstimateCoins(p95, headroomBps), recommendEstimateCoins(max, headroomBps));
25747
+ var PICOS_PER_CENT = 10000000000n;
25748
+ function ceilingCreditsFor(costPicos, terms) {
25749
+ if (costPicos <= 0n) return 1;
25750
+ const num = costPicos * BigInt(1e4 + terms.feeBps) * BigInt(1e4 + terms.headroomBps);
25751
+ const den = 100000000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
25752
+ return Math.max(1, Number((num + den - 1n) / den));
25753
+ }
25754
+ function averageCreditsPerCall(costsPicos, terms) {
25755
+ if (costsPicos.length === 0) return null;
25756
+ const sum = costsPicos.reduce((a, b) => a + b, 0n);
25757
+ return Number(sum * BigInt(1e4 + terms.feeBps)) / Number(BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT);
25758
+ }
25759
+ function perCallEstimateCreditsFor(costsPicos, terms) {
25760
+ if (costsPicos.length === 0) return null;
25761
+ const sum = costsPicos.reduce((a, b) => a + b, 0n);
25762
+ const num = sum * BigInt(1e4 + terms.feeBps);
25763
+ const den = BigInt(costsPicos.length) * 10000n * BigInt(terms.usdCentsPerCredit) * PICOS_PER_CENT;
25764
+ return Math.max(1, Number((num + den - 1n) / den));
25765
+ }
25766
+ function formatCredits(value) {
25767
+ if (!Number.isFinite(value)) return "\u2014";
25768
+ if (value >= 100) return String(Math.round(value));
25769
+ return String(Number(value.toPrecision(value < 1 ? 2 : 3)));
25770
+ }
25771
+ function usdFromPicos(picos) {
25772
+ if (picos === null || picos === void 0) return "unknown";
25773
+ const value = typeof picos === "bigint" ? picos : /^\d+$/.test(picos) ? BigInt(picos) : null;
25774
+ if (value === null) return "unknown";
25775
+ return `$${(Number(value) / 1e12).toFixed(6)}`;
24477
25776
  }
24478
25777
  function usdPerMillion(value) {
24479
25778
  return `$${value.toFixed(value < 1 ? 4 : 2)}`;
24480
25779
  }
24481
- function usd(value) {
24482
- return value === null ? "unknown" : `$${value.toFixed(6)}`;
24483
- }
24484
25780
  async function runLlm(opts = {}) {
24485
25781
  const log = createLogger({ quiet: opts.quiet || opts.json });
24486
25782
  const cwd = opts.cwd ?? process.cwd();
@@ -24490,8 +25786,8 @@ async function runLlm(opts = {}) {
24490
25786
  else log.error(message);
24491
25787
  process.exitCode = 1;
24492
25788
  };
24493
- if (!SUBS3.includes(sub)) {
24494
- fail5(`llm ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS3.join(", ")}.`);
25789
+ if (!SUBS4.includes(sub)) {
25790
+ fail5(`llm ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS4.join(", ")}.`);
24495
25791
  return;
24496
25792
  }
24497
25793
  if (sub === "price") {
@@ -24506,16 +25802,23 @@ async function runLlm(opts = {}) {
24506
25802
  return;
24507
25803
  }
24508
25804
  if (sub === "bench") {
24509
- if (opts.maxCoins === void 0 || opts.userApproved !== true) {
25805
+ if (opts.maxCoins !== void 0) process.stderr.write(`${c.yellow("!")} ${LLM_MAX_COINS_DEPRECATED}
25806
+ `);
25807
+ if (opts.maxCoins !== void 0 && opts.maxCredits !== void 0 && opts.maxCoins !== opts.maxCredits) {
25808
+ fail5("llm bench", `Pass --max-credits alone \u2014 --max-coins is its deprecated name, and the two disagree (${opts.maxCredits} vs ${opts.maxCoins}).`);
25809
+ return;
25810
+ }
25811
+ const approvedCredits = opts.maxCredits ?? opts.maxCoins;
25812
+ if (approvedCredits === void 0 || opts.userApproved !== true) {
24510
25813
  fail5("llm bench", LLM_BENCH_APPROVAL_REQUIRED);
24511
25814
  return;
24512
25815
  }
24513
- if (!Number.isInteger(opts.maxCoins) || opts.maxCoins < 1) {
24514
- fail5("llm bench", `--max-coins takes a whole number of coin, 1 or more (got ${String(opts.maxCoins)}).`);
25816
+ if (!Number.isInteger(approvedCredits) || approvedCredits < 1) {
25817
+ fail5("llm bench", `--max-credits takes a whole number of credits, 1 or more (got ${String(approvedCredits)}).`);
24515
25818
  return;
24516
25819
  }
24517
25820
  if (!opts.benchPrompt?.trim()) {
24518
- fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-coins <n> --user-approved.');
25821
+ fail5("llm bench", '`genex llm bench` needs the prompt your game would send, e.g. genex llm bench "<prompt>" --max-credits <n> --user-approved.');
24519
25822
  return;
24520
25823
  }
24521
25824
  if (opts.jsonOutput && opts.textOutput) {
@@ -24607,6 +25910,10 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
24607
25910
  models: null,
24608
25911
  externalProviders: null,
24609
25912
  recommendedDeclaredHeadroomBps: null,
25913
+ currency: null,
25914
+ feeBps: null,
25915
+ usdCentsPerCredit: null,
25916
+ standingCreditGrant: null,
24610
25917
  total: null,
24611
25918
  featuredCount: null,
24612
25919
  // The stand was never asked, so the verdict is unknown — but the key is
@@ -24632,6 +25939,12 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
24632
25939
  models: lane.models,
24633
25940
  externalProviders: lane.externalProviders,
24634
25941
  recommendedDeclaredHeadroomBps: lane.recommendedDeclaredHeadroomBps,
25942
+ // The credit terms a call is billed on, the server's — null on a stand
25943
+ // that predates credit billing, never a number the CLI filled in.
25944
+ currency: lane.currency,
25945
+ feeBps: lane.feeBps,
25946
+ usdCentsPerCredit: lane.usdCentsPerCredit,
25947
+ standingCreditGrant: lane.standingCreditGrant,
24635
25948
  total: lane.total ?? lane.models?.length ?? null,
24636
25949
  featuredCount: lane.featuredCount ?? (lane.models ? lane.models.filter((m) => m.featured).length : null),
24637
25950
  // `null` when the lane is not live or the server predates the probe.
@@ -24678,8 +25991,9 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
24678
25991
  );
24679
25992
  for (const m of shown) {
24680
25993
  const plan = m.personalPlan ? ` \xB7 personal plan: ${m.personalPlan}` : "";
25994
+ const kind = m.kind === "typesafe" ? " \xB7 judge (classifier): judge() under a budget, not generate() or bench" : "";
24681
25995
  log.plain(
24682
- ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}`
25996
+ ` ${c.cyan(m.id)} ${m.label} \u2014 ${usdPerMillion(m.inputUsdPerMillion)} in / ${usdPerMillion(m.outputUsdPerMillion)} out per million tokens${plan}${kind}`
24683
25997
  );
24684
25998
  }
24685
25999
  if (hiddenCount > 0) {
@@ -24695,74 +26009,104 @@ async function reportModels(apiUrl, token, opts, log, toolsOnly = false) {
24695
26009
  }
24696
26010
  log.plain("");
24697
26011
  if (lane.recommendedDeclaredHeadroomBps === null) {
24698
- log.dim(" This stand serves no recommended headroom, so no price can be recommended from a bench.");
26012
+ log.dim(" This stand serves no recommended headroom, so no ceiling can be recommended from a bench.");
26013
+ } else {
26014
+ log.dim(` Recommended headroom over a benchmarked call's cost on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
26015
+ }
26016
+ if (lane.feeBps === null) {
26017
+ log.dim(" This stand serves no platform fee \u2014 it predates credit billing, so no ceiling can be recommended from a bench.");
24699
26018
  } else {
24700
- log.dim(` Recommended headroom over benchmarked coins on this stand: ${lane.recommendedDeclaredHeadroomBps} bps.`);
26019
+ log.dim(` In-game calls are billed in the player's credits, as used: the provider's cost plus this stand's platform fee (${lane.feeBps} bps).`);
24701
26020
  }
24702
26021
  if (toolsOnly) {
24703
26022
  printConvertHint();
24704
26023
  return;
24705
26024
  }
24706
- log.dim(" Those per-million rates are the PROVIDER's; what your game is charged is the platform");
24707
- log.dim(` tariff on top, which only a real call reveals: ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')}`);
26025
+ log.dim(" Those per-million rates are the PROVIDER's list; what one call of YOUR prompt really costs,");
26026
+ log.dim(` and the per-call ceiling to declare, only a real call measures: ${c.cyan('genex llm bench "<prompt>" --max-credits <n> --user-approved')}`);
24708
26027
  }
24709
26028
  function wholeOrNull(value) {
24710
26029
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
24711
26030
  }
26031
+ function costPicosOf(usage) {
26032
+ if (typeof usage?.costUsdPicos === "string" && /^\d+$/.test(usage.costUsdPicos)) return usage.costUsdPicos;
26033
+ if (typeof usage?.costUsd === "number" && Number.isFinite(usage.costUsd) && usage.costUsd >= 0) {
26034
+ return String(BigInt(Math.round(usage.costUsd * 1e12)));
26035
+ }
26036
+ return null;
26037
+ }
24712
26038
  function sampleFrom(id, settled) {
24713
26039
  const usage = settled?.usage;
26040
+ const picos = costPicosOf(usage);
24714
26041
  return {
24715
26042
  id,
24716
26043
  status: settled?.status ?? null,
24717
26044
  billingStatus: settled?.billingStatus ?? null,
24718
- chargedCoins: typeof settled?.chargedCoins === "number" ? settled.chargedCoins : null,
24719
- costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : null,
26045
+ chargedCredits: wholeOrNull(settled?.chargedCredits),
26046
+ costUsd: typeof usage?.costUsd === "number" ? usage.costUsd : picos !== null ? Number(picos) / 1e12 : null,
26047
+ costUsdPicos: picos,
24720
26048
  error: settled?.error ?? (settled ? null : "timed_out"),
24721
26049
  providerMessage: typeof settled?.providerMessage === "string" && settled.providerMessage.trim() ? settled.providerMessage.trim() : null,
24722
26050
  inputTokens: wholeOrNull(usage?.inputTokens),
24723
26051
  outputTokens: wholeOrNull(usage?.outputTokens),
24724
26052
  maxOutputTokens: wholeOrNull(usage?.maxOutputTokens),
24725
- fitCoins: typeof usage?.fitCoins === "number" && Number.isSafeInteger(usage.fitCoins) && usage.fitCoins >= 1 ? usage.fitCoins : null,
26053
+ fitCredits: typeof usage?.fitCredits === "number" && Number.isSafeInteger(usage.fitCredits) && usage.fitCredits >= 1 ? usage.fitCredits : null,
24726
26054
  fitExceedsCap: typeof usage?.fitExceedsCap === "boolean" ? usage.fitExceedsCap : null,
24727
26055
  truncated: typeof usage?.truncated === "boolean" ? usage.truncated : settled?.error === "provider_token_limit" ? true : null
24728
26056
  };
24729
26057
  }
24730
26058
  var PROVIDER_TOKEN_LIMIT = "provider_token_limit";
24731
- function benchRecommendation(results, headroomBps) {
24732
- const settled = results.filter((r) => r.status === "succeeded" && r.billingStatus === "final" && typeof r.chargedCoins === "number");
24733
- const charged = settled.map((r) => r.chargedCoins);
24734
- const fits = settled.map((r) => r.fitCoins).filter((v) => typeof v === "number");
24735
- const p95 = percentile2(charged, 95);
24736
- const max = charged.length ? Math.max(...charged) : null;
26059
+ function benchRecommendation(results, terms) {
26060
+ const settled = results.filter(
26061
+ (r) => r.status === "succeeded" && r.billingStatus === "final" && r.chargedCredits !== null && r.costUsdPicos !== null
26062
+ );
26063
+ const costs = settled.map((r) => BigInt(r.costUsdPicos));
26064
+ const charged = settled.map((r) => r.chargedCredits);
26065
+ const fits = settled.map((r) => r.fitCredits).filter((v) => typeof v === "number");
26066
+ const costP95 = percentilePicos(costs, 95);
26067
+ const costMax = costs.length ? costs.reduce((a, b) => b > a ? b : a) : null;
24737
26068
  const fitP95 = percentile2(fits, 95);
24738
26069
  const fitMax = fits.length ? Math.max(...fits) : null;
24739
26070
  const exceedsStandCap = results.some((r) => r.fitExceedsCap === true);
24740
26071
  const cutOffSamples = results.filter((r) => r.error === PROVIDER_TOKEN_LIMIT && r.fitExceedsCap !== true).length;
24741
26072
  const noRecommendationReason = exceedsStandCap ? "answer_exceeds_stand_cap" : cutOffSamples > 0 ? "answer_cut_off" : null;
24742
- const chargedBased = p95 !== null && headroomBps !== null ? recommendEstimateCoins(p95, headroomBps) : null;
24743
- const recommended = chargedBased === null || noRecommendationReason !== null ? null : Math.max(chargedBased, fitP95 ?? 0);
24744
- const ceiling = recommended === null || p95 === null || max === null || headroomBps === null ? null : Math.max(recommendPerCallMaxCoins(p95, max, headroomBps), recommended, fitMax ?? 0);
26073
+ const { headroomBps, feeBps, usdCentsPerCredit } = terms;
26074
+ const missingTerms = headroomBps === null ? "headroom" : feeBps === null || usdCentsPerCredit === null ? "credit_terms" : null;
26075
+ const full = headroomBps !== null && feeBps !== null && usdCentsPerCredit !== null ? { headroomBps, feeBps, usdCentsPerCredit } : null;
26076
+ const billing = feeBps !== null && usdCentsPerCredit !== null ? { feeBps, usdCentsPerCredit } : null;
26077
+ const costBased = full !== null && costP95 !== null ? ceilingCreditsFor(costP95, full) : null;
26078
+ const recommended = costBased === null || noRecommendationReason !== null ? null : Math.max(costBased, fitP95 ?? 0);
26079
+ const ceiling = recommended === null || costMax === null || full === null ? null : Math.max(ceilingCreditsFor(costMax, full), recommended, fitMax ?? 0);
26080
+ const average = billing !== null ? averageCreditsPerCall(costs, billing) : null;
26081
+ const estimate = ceiling === null || billing === null ? null : Math.min(ceiling, perCallEstimateCreditsFor(costs, billing) ?? 1);
24745
26082
  return {
26083
+ costs,
24746
26084
  charged,
24747
- p50: percentile2(charged, 50),
24748
- p95,
24749
- max,
26085
+ costP50: percentilePicos(costs, 50),
26086
+ costP95,
26087
+ costMax,
26088
+ chargedP50: percentile2(charged, 50),
26089
+ chargedP95: percentile2(charged, 95),
26090
+ chargedMax: charged.length ? Math.max(...charged) : null,
24750
26091
  fits,
24751
26092
  fitP95,
24752
26093
  fitMax,
24753
- chargedBasedEstimateCoins: chargedBased,
24754
- recommendedEstimateCoins: recommended,
24755
- recommendedPerCallMaxCoins: ceiling,
24756
- lengthRaisedPrice: recommended !== null && chargedBased !== null && recommended > chargedBased,
26094
+ costBasedMaxCredits: costBased,
26095
+ recommendedMaxCredits: recommended,
26096
+ recommendedPerCallMaxCredits: ceiling,
26097
+ averageCreditsPerCall: average,
26098
+ recommendedPerCallEstimateCredits: estimate,
26099
+ lengthRaisedCeiling: recommended !== null && costBased !== null && recommended > costBased,
24757
26100
  cutOffSamples,
24758
26101
  exceedsStandCap,
24759
- noRecommendationReason
26102
+ noRecommendationReason,
26103
+ missingTerms
24760
26104
  };
24761
26105
  }
24762
- var LLM_LENGTH_RAISED_LINE = "The declared price also pays for how long the answer may be: at the charged-based price the answer would be cut off, so declare this one.";
24763
- var LLM_EXCEEDS_STAND_CAP_LINE = "No price recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
24764
- function benchCutOffLine(cutOff, maxCoins) {
24765
- return `No price recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-coins ${maxCoins}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-coins.`;
26106
+ var LLM_LENGTH_RAISED_LINE = "The ceiling also decides how long the answer may be: at the cost-based ceiling the answer would be cut off, so declare this one.";
26107
+ var LLM_EXCEEDS_STAND_CAP_LINE = "No ceiling recommended: this answer is longer than one call on this stand may produce. Ask for a shorter answer \u2014 fewer fields, shorter strings, a length the prompt states \u2014 and benchmark again.";
26108
+ function benchCutOffLine(cutOff, maxCredits) {
26109
+ return `No ceiling recommended: ${cutOff} sample${cutOff === 1 ? " was" : "s were"} cut off at this ceiling (--max-credits ${maxCredits}) before the answer was finished, so its real length is unknown. Re-run with a higher --max-credits.`;
24766
26110
  }
24767
26111
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["requires_confirmation", "queued", "dispatching", "awaiting_external"]);
24768
26112
  function rowState(row) {
@@ -24773,7 +26117,22 @@ function rowState(row) {
24773
26117
  function rowHoldsSlot(row) {
24774
26118
  if (typeof row.slotHeld === "boolean") return row.slotHeld;
24775
26119
  const state = rowState(row);
24776
- return state === "active" || state === "awaiting bill" && (row.reservedCoins ?? 0) > 0;
26120
+ return state === "active" || state === "awaiting bill" && ((row.reservedCoins ?? 0) > 0 || (row.reservedCredits ?? 0) > 0);
26121
+ }
26122
+ function rowMoney(row) {
26123
+ const credits = row.currency === "credits" || row.currency === void 0 && typeof row.reservedCredits === "number" && typeof row.reservedCoins !== "number";
26124
+ if (credits) {
26125
+ return {
26126
+ unit: "credits",
26127
+ reserved: typeof row.reservedCredits === "number" ? row.reservedCredits : null,
26128
+ charged: typeof row.chargedCredits === "number" ? row.chargedCredits : null
26129
+ };
26130
+ }
26131
+ return {
26132
+ unit: "coin",
26133
+ reserved: typeof row.reservedCoins === "number" ? row.reservedCoins : null,
26134
+ charged: typeof row.chargedCoins === "number" ? row.chargedCoins : null
26135
+ };
24777
26136
  }
24778
26137
  function providerRefusalStatus(error) {
24779
26138
  const m = /^provider_http_(\d{3})$/.exec(error ?? "");
@@ -24792,7 +26151,7 @@ function formatAge(iso, now = Date.now()) {
24792
26151
  }
24793
26152
  async function runBench(args) {
24794
26153
  const { apiUrl, token, projectId, cwd, opts, log } = args;
24795
- const maxCoins = opts.maxCoins;
26154
+ const maxCredits = opts.maxCredits ?? opts.maxCoins;
24796
26155
  const samples = opts.samples ?? DEFAULT_BENCH_SAMPLES;
24797
26156
  const prompt = opts.benchPrompt.trim();
24798
26157
  const auth = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
@@ -24800,7 +26159,7 @@ async function runBench(args) {
24800
26159
  let schema;
24801
26160
  if (opts.schemaPath) {
24802
26161
  try {
24803
- schema = JSON.parse(await fs35.readFile(path36.resolve(cwd, opts.schemaPath), "utf8"));
26162
+ schema = JSON.parse(await fs38.readFile(path38.resolve(cwd, opts.schemaPath), "utf8"));
24804
26163
  } catch (err) {
24805
26164
  benchFailed(opts, log, `Couldn't read --schema ${opts.schemaPath}: ${err instanceof Error ? err.message : String(err)}`);
24806
26165
  return;
@@ -24814,7 +26173,7 @@ async function runBench(args) {
24814
26173
  const lane = await readRuntimeLane(apiUrl, token);
24815
26174
  if (lane.state !== "live" || !lane.models || lane.models.length === 0) {
24816
26175
  if (lane.state === "off") {
24817
- if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts), status: "off", error: null });
26176
+ if (opts.json) writeJsonLine({ ...emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts), status: "off", error: null });
24818
26177
  else {
24819
26178
  log.plain(LLM_LANE_OFF_LINE);
24820
26179
  log.dim(" Nothing was spent.");
@@ -24841,17 +26200,27 @@ async function runBench(args) {
24841
26200
  );
24842
26201
  return;
24843
26202
  }
24844
- const balanceBefore = await readCoinBalance(apiUrl, token);
26203
+ const walletBefore = await fetchCreditsSnapshot(apiUrl, token);
26204
+ if (walletBefore?.emailVerified === false) {
26205
+ benchFailed(opts, log, LLM_BENCH_UNVERIFIED_LINE);
26206
+ return;
26207
+ }
26208
+ if (walletBefore !== null && walletBefore.spendable < maxCredits) {
26209
+ benchFailed(opts, log, benchBalanceShortLine(walletBefore.spendable, maxCredits));
26210
+ return;
26211
+ }
26212
+ const balanceBefore = walletBefore?.spendable ?? null;
24845
26213
  if (!opts.json) {
24846
26214
  log.plain(c.bold("genex llm bench"));
24847
26215
  log.dim(` ${apiUrl}`);
24848
26216
  log.plain("");
24849
26217
  log.plain(` Model ${c.cyan(modelId)}`);
24850
26218
  log.plain(` Samples ${samples} real attempt${samples === 1 ? "" : "s"}, ${outputFormat} output`);
24851
- log.plain(` Approved ${maxCoins} coin per attempt \u2014 at worst ${maxCoins * samples} coin for this run`);
26219
+ log.plain(` Approved ${maxCredits} credit${maxCredits === 1 ? "" : "s"} per attempt \u2014 at worst ${maxCredits * samples} credits for this run, from your own balance`);
24852
26220
  log.plain(
24853
- ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} coin spendable`}`
26221
+ ` Balance ${balanceBefore === null ? "couldn't be read" : `${balanceBefore} credits spendable`}`
24854
26222
  );
26223
+ log.dim(" Billed as used: each attempt is charged its real cost plus the platform fee, rounded up to a whole credit.");
24855
26224
  log.plain("");
24856
26225
  }
24857
26226
  const base = `${apiUrl}/api/runtime/development/projects/${encodeURIComponent(projectId)}/generations`;
@@ -24877,7 +26246,7 @@ async function runBench(args) {
24877
26246
  const res = await call2(base, {
24878
26247
  method: "POST",
24879
26248
  body: JSON.stringify({
24880
- maxCoins,
26249
+ maxCredits,
24881
26250
  request: {
24882
26251
  idempotencyKey: `bench-${randomUUID2()}`,
24883
26252
  modelId,
@@ -24907,13 +26276,14 @@ async function runBench(args) {
24907
26276
  if (refusedAt !== null) providerRefused++;
24908
26277
  if (!opts.json) {
24909
26278
  const length = row.outputTokens !== null ? ` \xB7 ${row.outputTokens}${row.maxOutputTokens !== null ? ` of ${row.maxOutputTokens}` : ""} tokens out` : "";
26279
+ const charged = row.chargedCredits === null ? "\u2014" : String(row.chargedCredits);
24910
26280
  log.plain(
24911
- ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${String(row.chargedCoins ?? "\u2014").padStart(4)} coin charged \xB7 provider ${usd(row.costUsd)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
26281
+ ` ${row.status === "succeeded" ? c.green("\u2713") : c.yellow("!")} sample ${i + 1} ${charged.padStart(4)} credit${row.chargedCredits === 1 ? "" : "s"} charged \xB7 cost ${usdFromPicos(row.costUsdPicos)}${length}${row.error ? ` \xB7 ${row.error}` : ""}`
24912
26282
  );
24913
26283
  if (refusedAt !== null) printProviderRefusal(log, refusedAt, row.providerMessage, row.status === "unknown");
24914
26284
  if (row.error === PROVIDER_TOKEN_LIMIT) {
24915
26285
  log.dim(
24916
- row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no price makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-coins ${maxCoins}) before it was finished \u2014 it was charged, and it is not a sample.`
26286
+ row.fitExceedsCap === true ? " The answer was cut off at this stand's own output limit \u2014 no ceiling makes room for it; it is not a sample." : ` The answer was cut off at this ceiling (--max-credits ${maxCredits}) before it was finished \u2014 it was charged, and it is not a sample.`
24917
26287
  );
24918
26288
  }
24919
26289
  }
@@ -24921,16 +26291,19 @@ async function runBench(args) {
24921
26291
  } finally {
24922
26292
  process.removeListener("SIGINT", onSigint);
24923
26293
  }
24924
- const headroomBps = lane.recommendedDeclaredHeadroomBps;
24925
- const rec = benchRecommendation(results, headroomBps);
24926
- const { charged, p50, p95, max } = rec;
24927
- const settledCount = charged.length;
24928
- const recommended = rec.recommendedEstimateCoins;
24929
- const ceiling = rec.recommendedPerCallMaxCoins;
26294
+ const terms = {
26295
+ headroomBps: lane.recommendedDeclaredHeadroomBps,
26296
+ feeBps: lane.feeBps,
26297
+ usdCentsPerCredit: lane.usdCentsPerCredit
26298
+ };
26299
+ const rec = benchRecommendation(results, terms);
26300
+ const settledCount = rec.charged.length;
24930
26301
  const lengthBlocked = rec.noRecommendationReason !== null;
24931
- const balanceAfter = await readCoinBalance(apiUrl, token);
26302
+ const balanceAfter = (await fetchCreditsSnapshot(apiUrl, token))?.spendable ?? null;
26303
+ const picosOrNull = (v) => v === null ? null : String(v);
24932
26304
  const record = {
24933
- v: 1,
26305
+ v: 2,
26306
+ currency: "credits",
24934
26307
  ranAt: (/* @__PURE__ */ new Date()).toISOString(),
24935
26308
  apiUrl,
24936
26309
  projectId,
@@ -24939,28 +26312,34 @@ async function runBench(args) {
24939
26312
  outputFormat,
24940
26313
  samples,
24941
26314
  completed: settledCount,
24942
- chargedCoins: charged,
24943
- p50,
24944
- p95,
24945
- max,
24946
- recommendedDeclaredHeadroomBps: headroomBps,
24947
- recommendedEstimateCoins: recommended,
24948
- recommendedPerCallMaxCoins: ceiling,
24949
- maxCoinsPerSample: maxCoins,
24950
- fitCoins: rec.fits,
26315
+ maxCreditsPerSample: maxCredits,
26316
+ costUsdPicos: rec.costs.map(String),
26317
+ chargedCredits: rec.charged,
26318
+ cost: { p50: picosOrNull(rec.costP50), p95: picosOrNull(rec.costP95), max: picosOrNull(rec.costMax) },
26319
+ charged: { p50: rec.chargedP50, p95: rec.chargedP95, max: rec.chargedMax },
26320
+ fitCredits: rec.fits,
24951
26321
  fitP95: rec.fitP95,
24952
- chargedBasedEstimateCoins: rec.chargedBasedEstimateCoins,
24953
- lengthRaisedPrice: rec.lengthRaisedPrice,
26322
+ fitMax: rec.fitMax,
26323
+ recommendedDeclaredHeadroomBps: terms.headroomBps,
26324
+ feeBps: terms.feeBps,
26325
+ usdCentsPerCredit: terms.usdCentsPerCredit,
26326
+ costBasedMaxCredits: rec.costBasedMaxCredits,
26327
+ recommendedMaxCredits: rec.recommendedMaxCredits,
26328
+ recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
26329
+ averageCreditsPerCall: rec.averageCreditsPerCall,
26330
+ recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
26331
+ lengthRaisedCeiling: rec.lengthRaisedCeiling,
24954
26332
  cutOffSamples: rec.cutOffSamples,
24955
26333
  noRecommendationReason: rec.noRecommendationReason
24956
26334
  };
24957
26335
  const savedTo = await saveBench(cwd, record);
24958
26336
  if (opts.json) {
24959
- const ok = charged.length > 0 && !lengthBlocked;
26337
+ const ok = settledCount > 0 && !lengthBlocked;
24960
26338
  writeJsonLine({
24961
26339
  command: "llm bench",
24962
26340
  status: ok ? "ok" : "failed",
24963
26341
  error: ok ? null : rec.noRecommendationReason ?? "no_sample_settled",
26342
+ currency: "credits",
24964
26343
  apiUrl,
24965
26344
  projectId,
24966
26345
  modelId,
@@ -24969,35 +26348,54 @@ async function runBench(args) {
24969
26348
  schemaPath: opts.schemaPath ?? null,
24970
26349
  samples,
24971
26350
  completed: settledCount,
24972
- maxCoinsPerSample: maxCoins,
26351
+ maxCreditsPerSample: maxCredits,
26352
+ // Spendable credits before and after the run; null when unreadable.
24973
26353
  balanceBefore,
24974
26354
  balanceAfter,
24975
26355
  results,
24976
26356
  // The create-time refusal that ended the run, or null. Beside the
24977
26357
  // samples, never among them: no attempt existed and nothing was spent.
24978
26358
  refusal: refusal2,
24979
- chargedCoins: { p50, p95, max },
24980
- // The answer's LENGTH, priced by the server per sample (`fitCoins`,
26359
+ // Over the settled samples: the real provider cost (pico-dollar strings
26360
+ // and USD) and the whole credits charged.
26361
+ costUsdPicos: record.cost,
26362
+ costUsd: {
26363
+ p50: rec.costP50 === null ? null : Number(rec.costP50) / 1e12,
26364
+ p95: rec.costP95 === null ? null : Number(rec.costP95) / 1e12,
26365
+ max: rec.costMax === null ? null : Number(rec.costMax) / 1e12
26366
+ },
26367
+ chargedCredits: record.charged,
26368
+ // The answer's LENGTH, priced by the server per sample (`fitCredits`,
24981
26369
  // headroom already on the tokens), over the settled samples.
24982
- fitCoins: { p95: rec.fitP95, max: rec.fitMax },
24983
- chargedBasedEstimateCoins: rec.chargedBasedEstimateCoins,
24984
- lengthRaisedPrice: rec.lengthRaisedPrice,
26370
+ fitCredits: { p95: rec.fitP95, max: rec.fitMax },
26371
+ recommendedDeclaredHeadroomBps: terms.headroomBps,
26372
+ feeBps: terms.feeBps,
26373
+ usdCentsPerCredit: terms.usdCentsPerCredit,
26374
+ missingTerms: rec.missingTerms,
26375
+ costBasedMaxCredits: rec.costBasedMaxCredits,
26376
+ lengthRaisedCeiling: rec.lengthRaisedCeiling,
24985
26377
  cutOffSamples: rec.cutOffSamples,
24986
26378
  exceedsStandCap: rec.exceedsStandCap,
24987
26379
  noRecommendationReason: rec.noRecommendationReason,
24988
- recommendedDeclaredHeadroomBps: headroomBps,
24989
- recommendedEstimateCoins: recommended,
24990
- recommendedPerCallMaxCoins: ceiling,
26380
+ recommendedMaxCredits: rec.recommendedMaxCredits,
26381
+ recommendedPerCallMaxCredits: rec.recommendedPerCallMaxCredits,
26382
+ averageCreditsPerCall: rec.averageCreditsPerCall,
26383
+ recommendedPerCallEstimateCredits: rec.recommendedPerCallEstimateCredits,
24991
26384
  savedTo
24992
26385
  });
24993
26386
  if (!ok) process.exitCode = 1;
24994
26387
  return;
24995
26388
  }
24996
26389
  log.plain("");
26390
+ const printMeasured = () => {
26391
+ log.plain(c.bold(" Real cost per call"));
26392
+ log.plain(` p50 ${usdFromPicos(rec.costP50)} p95 ${usdFromPicos(rec.costP95)} max ${usdFromPicos(rec.costMax)} (${settledCount} of ${samples} settled)`);
26393
+ log.plain(c.bold(" Charged credits"));
26394
+ log.plain(` p50 ${rec.chargedP50} p95 ${rec.chargedP95} max ${rec.chargedMax} (whole credits, rounded up per call)`);
26395
+ };
24997
26396
  if (lengthBlocked) {
24998
- if (charged.length > 0) {
24999
- log.plain(c.bold(" Charged coins"));
25000
- log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
26397
+ if (settledCount > 0) {
26398
+ printMeasured();
25001
26399
  log.plain("");
25002
26400
  }
25003
26401
  printRecommendation(log, record);
@@ -25005,17 +26403,16 @@ async function runBench(args) {
25005
26403
  process.exitCode = 1;
25006
26404
  return;
25007
26405
  }
25008
- if (charged.length === 0) {
26406
+ if (settledCount === 0) {
25009
26407
  log.error(
25010
- providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to price from." : " No sample settled, so there is nothing to price from."
26408
+ providerRefused > 0 && providerRefused === results.length ? " No sample ran \u2014 the provider refused every attempt at its door \u2014 so there is nothing to measure from." : " No sample settled, so there is nothing to measure from."
25011
26409
  );
25012
26410
  process.exitCode = 1;
25013
26411
  return;
25014
26412
  }
25015
- log.plain(c.bold(" Charged coins"));
25016
- log.plain(` p50 ${p50} p95 ${p95} max ${max} (${charged.length} of ${samples} settled)`);
26413
+ printMeasured();
25017
26414
  if (balanceBefore !== null && balanceAfter !== null) {
25018
- log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} coin spendable`);
26415
+ log.plain(` Balance ${balanceBefore} \u2192 ${balanceAfter} credits spendable`);
25019
26416
  }
25020
26417
  log.plain("");
25021
26418
  printRecommendation(log, record);
@@ -25027,43 +26424,58 @@ function printRecommendation(log, record) {
25027
26424
  return;
25028
26425
  }
25029
26426
  if (record.noRecommendationReason === "answer_cut_off") {
25030
- log.warn(` ${benchCutOffLine(record.cutOffSamples ?? 1, record.maxCoinsPerSample ?? 0)}`);
26427
+ log.warn(` ${benchCutOffLine(record.cutOffSamples || 1, record.maxCreditsPerSample)}`);
25031
26428
  return;
25032
26429
  }
25033
- if (record.recommendedEstimateCoins === null || record.p95 === null) {
25034
- log.warn(" No price recommended.");
26430
+ const attempts = `${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}`;
26431
+ if (record.recommendedMaxCredits === null || record.cost.p95 === null) {
26432
+ log.warn(" No ceiling recommended.");
25035
26433
  log.dim(
25036
- record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " No sample settled, so there is no p95 to build a price on \u2014"
26434
+ record.cost.p95 === null ? " No sample settled, so there is no p95 to build a ceiling on \u2014" : record.recommendedDeclaredHeadroomBps === null ? " This stand served no recommended headroom, and the multiplier is the server's to set \u2014" : " This stand served no platform fee or credit value, and those are the server's to set \u2014"
25037
26435
  );
25038
26436
  log.dim(" declaring a number from this run would be a guess dressed as a measurement.");
26437
+ printAverage(log, record, attempts);
25039
26438
  return;
25040
26439
  }
25041
- log.plain(c.bold(` Declare estimateCoins: ${record.recommendedEstimateCoins}`));
25042
- if (record.lengthRaisedPrice) {
26440
+ log.plain(c.bold(` Declare maxCredits: ${record.recommendedMaxCredits}`));
26441
+ if (record.lengthRaisedCeiling) {
25043
26442
  log.plain(` ${LLM_LENGTH_RAISED_LINE}`);
25044
26443
  log.dim(
25045
- ` = the smallest price whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
26444
+ ` = the smallest ceiling whose answer allowance holds the answer plus this stand's headroom (p95 ${record.fitP95 ?? "\u2014"}), above the`
25046
26445
  );
25047
26446
  log.dim(
25048
- ` charged-based ${record.chargedBasedEstimateCoins ?? "\u2014"} (p95 of charged coins ${record.p95} plus ${record.recommendedDeclaredHeadroomBps} bps), over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`
26447
+ ` cost-based ${record.costBasedMaxCredits ?? "\u2014"} (p95 real cost ${usdFromPicos(record.cost.p95)} with the platform fee, ${record.feeBps} bps, and ${record.recommendedDeclaredHeadroomBps} bps of headroom), over ${attempts}.`
25049
26448
  );
25050
26449
  } else {
25051
26450
  log.dim(
25052
- ` = p95 of charged coins (${record.p95}) plus this stand's recommended headroom (${record.recommendedDeclaredHeadroomBps} bps),`
26451
+ ` = p95 real cost (${usdFromPicos(record.cost.p95)}) with this stand's platform fee (${record.feeBps} bps) and recommended headroom (${record.recommendedDeclaredHeadroomBps} bps), in whole credits,`
25053
26452
  );
25054
- log.dim(` measured over ${record.completed} settled attempt${record.completed === 1 ? "" : "s"} on ${record.modelId}.`);
26453
+ log.dim(` measured over ${attempts}.`);
25055
26454
  }
25056
- log.dim(" That number is a PRICE: a started attempt is charged in full, including one that fails.");
25057
- if (record.recommendedPerCallMaxCoins != null) {
25058
- log.plain(c.bold(` Grant perCallMaxCoins: ${record.recommendedPerCallMaxCoins}`));
26455
+ log.dim(" That number is a CEILING, not a price: a call is billed its real cost as used, never more than it \u2014");
26456
+ log.dim(" and it also sizes how long the answer may be.");
26457
+ if (record.recommendedPerCallMaxCredits !== null) {
26458
+ log.plain(c.bold(` Grant perCallMaxCredits: ${record.recommendedPerCallMaxCredits}`));
25059
26459
  log.dim(
25060
- record.lengthRaisedPrice ? ` = the same headroom over the worst sample (${record.max}) or the longest answer's price, whichever is larger, and never below the price above it.` : ` = the same headroom over the worst sample (${record.max}), and never below the price above it.`
26460
+ record.lengthRaisedCeiling ? ` = the same over the worst sample (${usdFromPicos(record.cost.max)}) or the longest answer's ceiling, whichever is larger, and never below the ceiling above it.` : ` = the same over the worst sample (${usdFromPicos(record.cost.max)}), and never below the ceiling above it.`
25061
26461
  );
25062
- log.dim(" The ceiling is the price's room to be wrong; declaring one under the price is refused.");
26462
+ }
26463
+ printAverage(log, record, attempts);
26464
+ }
26465
+ function printAverage(log, record, attempts) {
26466
+ if (record.averageCreditsPerCall === null) return;
26467
+ log.plain(` Per-call estimate: about ${formatCredits(record.averageCreditsPerCall)} credits per call`);
26468
+ log.dim(` = the average real cost with the platform fee, no headroom, over ${attempts}.`);
26469
+ if (record.recommendedPerCallEstimateCredits !== null) {
26470
+ log.plain(c.bold(` Grant perCallEstimateCredits: ${record.recommendedPerCallEstimateCredits}`));
26471
+ log.dim(` = that average rounded up to a whole credit. disclosure.estimatedCreditsPerPeriod = your calls per period \xD7 ${formatCredits(record.averageCreditsPerCall)}, rounded up.`);
25063
26472
  }
25064
26473
  }
25065
26474
  async function explainBenchRefusal(res, call2, base, log, opts, index, settledSoFar) {
25066
- if (printedStructuredError(res)) return { stop: true, refusal: { status: res.status, error: null, slotsHeld: null, slotLimit: null } };
26475
+ if (printedStructuredError(res)) {
26476
+ const printed = await res.json().catch(() => ({}));
26477
+ return { stop: true, refusal: { status: res.status, error: typeof printed.error === "string" ? printed.error : null, slotsHeld: null, slotLimit: null } };
26478
+ }
25067
26479
  const body = await res.json().catch(() => ({}));
25068
26480
  const refusal2 = { status: res.status, error: body.error ?? null, slotsHeld: null, slotLimit: null };
25069
26481
  if (res.status === 429 && body.error === "generation_limit") {
@@ -25073,6 +26485,10 @@ async function explainBenchRefusal(res, call2, base, log, opts, index, settledSo
25073
26485
  if (!opts.json) log.error(` Sample ${index + 1} ${benchSlotsHeldSentence(counts)}`);
25074
26486
  return { stop: true, refusal: refusal2 };
25075
26487
  }
26488
+ if (res.status === 403 && body.error === "credits_unverified") {
26489
+ if (!opts.json) log.error(` ${LLM_BENCH_UNVERIFIED_LINE}`);
26490
+ return { stop: true, refusal: refusal2 };
26491
+ }
25076
26492
  if (opts.json) return { stop: res.status !== 429, refusal: refusal2 };
25077
26493
  if (res.status === 404 && body.error === "not_found") {
25078
26494
  log.error(` ${LLM_LANE_OFF_LINE}`);
@@ -25088,7 +26504,7 @@ async function explainBenchRefusal(res, call2, base, log, opts, index, settledSo
25088
26504
  return { stop: true, refusal: refusal2 };
25089
26505
  }
25090
26506
  if (res.status === 402) {
25091
- log.error(" Not enough coin to start the attempt.");
26507
+ log.error(" Not enough credits to start the attempt \u2014 a bench is paid from your own balance.");
25092
26508
  return { stop: true, refusal: refusal2 };
25093
26509
  }
25094
26510
  log.error(
@@ -25097,7 +26513,7 @@ async function explainBenchRefusal(res, call2, base, log, opts, index, settledSo
25097
26513
  return { stop: res.status >= 500 || res.status === 401 || res.status === 403, refusal: refusal2 };
25098
26514
  }
25099
26515
  function printProviderRefusal(log, status2, message, awaitingBill = false) {
25100
- if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so this call's coin stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
26516
+ if (awaitingBill) log.dim(` The provider answered ${status2} after routing \u2014 its bill is not final, so what this call reserved stays held until it resolves (see \`genex llm status\`); it is not a sample.`);
25101
26517
  else log.dim(" The provider refused this call at its door \u2014 no inference ran, it cost nothing, and it is not a sample.");
25102
26518
  if (message) log.dim(` Provider said: ${message}`);
25103
26519
  if (status2 === 401 || status2 === 403) {
@@ -25123,24 +26539,11 @@ async function pollSettled(call2, url, timeoutSec) {
25123
26539
  }
25124
26540
  return last;
25125
26541
  }
25126
- async function readCoinBalance(apiUrl, token) {
25127
- try {
25128
- const res = await apiFetch(`${apiUrl}/api/coin/balance`, {
25129
- headers: { Authorization: `Bearer ${token}` },
25130
- signal: AbortSignal.timeout(6e3)
25131
- });
25132
- if (!res.ok) return null;
25133
- const body = await res.json().catch(() => null);
25134
- return typeof body?.spendable === "number" ? body.spendable : null;
25135
- } catch {
25136
- return null;
25137
- }
25138
- }
25139
26542
  async function saveBench(cwd, record) {
25140
26543
  try {
25141
- const file = path36.join(cwd, LLM_BENCH_FILE);
25142
- await fs35.mkdir(path36.dirname(file), { recursive: true });
25143
- await fs35.writeFile(file, JSON.stringify(record, null, 2) + "\n");
26544
+ const file = path38.join(cwd, LLM_BENCH_FILE);
26545
+ await fs38.mkdir(path38.dirname(file), { recursive: true });
26546
+ await fs38.writeFile(file, JSON.stringify(record, null, 2) + "\n");
25144
26547
  return LLM_BENCH_FILE;
25145
26548
  } catch {
25146
26549
  return null;
@@ -25151,9 +26554,10 @@ function benchFailed(opts, log, message) {
25151
26554
  else log.error(message);
25152
26555
  process.exitCode = 1;
25153
26556
  }
25154
- function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoins, opts) {
26557
+ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCredits, opts) {
25155
26558
  return {
25156
26559
  command: "llm bench",
26560
+ currency: "credits",
25157
26561
  apiUrl,
25158
26562
  projectId,
25159
26563
  modelId: opts.modelId ?? null,
@@ -25162,21 +26566,28 @@ function emptyBenchJson(apiUrl, projectId, prompt, outputFormat, samples, maxCoi
25162
26566
  schemaPath: opts.schemaPath ?? null,
25163
26567
  samples,
25164
26568
  completed: 0,
25165
- maxCoinsPerSample: maxCoins,
26569
+ maxCreditsPerSample: maxCredits,
25166
26570
  balanceBefore: null,
25167
26571
  balanceAfter: null,
25168
26572
  results: [],
25169
26573
  refusal: null,
25170
- chargedCoins: { p50: null, p95: null, max: null },
25171
- fitCoins: { p95: null, max: null },
25172
- chargedBasedEstimateCoins: null,
25173
- lengthRaisedPrice: false,
26574
+ costUsdPicos: { p50: null, p95: null, max: null },
26575
+ costUsd: { p50: null, p95: null, max: null },
26576
+ chargedCredits: { p50: null, p95: null, max: null },
26577
+ fitCredits: { p95: null, max: null },
26578
+ recommendedDeclaredHeadroomBps: null,
26579
+ feeBps: null,
26580
+ usdCentsPerCredit: null,
26581
+ missingTerms: null,
26582
+ costBasedMaxCredits: null,
26583
+ lengthRaisedCeiling: false,
25174
26584
  cutOffSamples: 0,
25175
26585
  exceedsStandCap: false,
25176
26586
  noRecommendationReason: null,
25177
- recommendedDeclaredHeadroomBps: null,
25178
- recommendedEstimateCoins: null,
25179
- recommendedPerCallMaxCoins: null,
26587
+ recommendedMaxCredits: null,
26588
+ recommendedPerCallMaxCredits: null,
26589
+ averageCreditsPerCall: null,
26590
+ recommendedPerCallEstimateCredits: null,
25180
26591
  savedTo: null
25181
26592
  };
25182
26593
  }
@@ -25239,7 +26650,8 @@ async function reportStatus(args) {
25239
26650
  for (const row of rows) {
25240
26651
  const state = rowState(row);
25241
26652
  const mark = state === "active" ? c.cyan("\u25CF") : state === "awaiting bill" ? c.yellow("\u25D0") : c.green("\u25CB");
25242
- const reserved = typeof row.reservedCoins === "number" ? `${row.reservedCoins} coin reserved` : "reserved \u2014";
26653
+ const money = rowMoney(row);
26654
+ const reserved = money.reserved === null ? "reserved \u2014" : `${money.reserved} ${money.unit} reserved`;
25243
26655
  log.plain(
25244
26656
  ` ${mark} ${row.id ?? "?"} ${row.modelId ?? "?"} ${state.padEnd(13)} ${formatAge(row.createdAt, now).padStart(4)} old ${reserved}${row.error ? ` ${row.error}` : ""}${rowHoldsSlot(row) ? "" : c.dim(" (no slot)")}`
25245
26657
  );
@@ -25311,10 +26723,11 @@ async function cancelCall(args) {
25311
26723
  if (!opts.json) {
25312
26724
  log.warn(` ${row.id} (${row.status ?? "?"}) \u2014 ${sentence}.`);
25313
26725
  if (state === "awaiting bill") {
25314
- log.dim(` Nothing to cancel: its coin releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
26726
+ log.dim(` Nothing to cancel: what it reserved releases when the bill resolves, and it stops holding a slot ${PENDING_SLOT_RELEASE_MINUTES} minutes after dispatch.`);
25315
26727
  if (row.providerMessage?.trim()) log.dim(` Provider said: ${row.providerMessage.trim()}`);
25316
26728
  } else {
25317
- log.dim(` ${typeof row.chargedCoins === "number" ? `${row.chargedCoins} coin charged` : "charge unknown"}${row.error ? ` \xB7 ${row.error}` : ""}.`);
26729
+ const money = rowMoney(row);
26730
+ log.dim(` ${money.charged === null ? "charge unknown" : `${money.charged} ${money.unit} charged`}${row.error ? ` \xB7 ${row.error}` : ""}.`);
25318
26731
  }
25319
26732
  }
25320
26733
  return done("ok", null, state === "awaiting bill" ? "awaiting_bill" : "already_final", row);
@@ -25336,78 +26749,126 @@ async function cancelCall(args) {
25336
26749
  const after = await res.json().catch(() => null) ?? row;
25337
26750
  if (!opts.json) {
25338
26751
  log.success(` Cancelled ${row.id} (was ${row.status ?? "active"}).`);
25339
- log.dim(` Its coin releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
26752
+ log.dim(` What it reserved releases once the bill settles; ${c.cyan("genex llm status")} shows it until then.`);
25340
26753
  }
25341
26754
  return done("ok", null, "cancelled", after);
25342
26755
  }
25343
- async function reportSavedPrice(cwd, opts, log) {
25344
- let record = null;
26756
+ async function readSavedBench(cwd) {
26757
+ let parsed;
25345
26758
  try {
25346
- record = JSON.parse(await fs35.readFile(path36.join(cwd, LLM_BENCH_FILE), "utf8"));
26759
+ parsed = JSON.parse(await fs38.readFile(path38.join(cwd, LLM_BENCH_FILE), "utf8"));
25347
26760
  } catch {
25348
- record = null;
26761
+ return null;
25349
26762
  }
25350
- if (!record) {
26763
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
26764
+ const v = parsed;
26765
+ if (v.v === 2 && v.currency === "credits" && v.cost && v.charged) return { kind: "credits", record: parsed };
26766
+ return { kind: "coins", record: parsed };
26767
+ }
26768
+ var LLM_PRICE_COIN_TERMS_LINE = "This bench was measured under the old COIN terms, when a game declared a fixed price per call. In-game calls are now billed in the player's credits, as used, and a game declares a per-call CEILING (maxCredits) instead \u2014 a coin price is not that number. Re-run the bench for the figures to declare.";
26769
+ async function reportSavedPrice(cwd, opts, log) {
26770
+ const saved = await readSavedBench(cwd);
26771
+ const rerun = 'genex llm bench "<prompt>" --max-credits <n> --user-approved';
26772
+ const creditKeys = (record2) => ({
26773
+ costUsdPicos: record2?.cost ?? { p50: null, p95: null, max: null },
26774
+ chargedCredits: record2?.charged ?? { p50: null, p95: null, max: null },
26775
+ fitP95: record2?.fitP95 ?? null,
26776
+ fitMax: record2?.fitMax ?? null,
26777
+ recommendedDeclaredHeadroomBps: record2?.recommendedDeclaredHeadroomBps ?? null,
26778
+ feeBps: record2?.feeBps ?? null,
26779
+ usdCentsPerCredit: record2?.usdCentsPerCredit ?? null,
26780
+ costBasedMaxCredits: record2?.costBasedMaxCredits ?? null,
26781
+ lengthRaisedCeiling: record2?.lengthRaisedCeiling ?? false,
26782
+ cutOffSamples: record2?.cutOffSamples ?? 0,
26783
+ noRecommendationReason: record2?.noRecommendationReason ?? null,
26784
+ recommendedMaxCredits: record2?.recommendedMaxCredits ?? null,
26785
+ recommendedPerCallMaxCredits: record2?.recommendedPerCallMaxCredits ?? null,
26786
+ averageCreditsPerCall: record2?.averageCreditsPerCall ?? null,
26787
+ recommendedPerCallEstimateCredits: record2?.recommendedPerCallEstimateCredits ?? null
26788
+ });
26789
+ if (!saved) {
25351
26790
  if (opts.json) {
25352
26791
  writeJsonLine({
25353
26792
  command: "llm price",
25354
26793
  status: "failed",
25355
26794
  error: "no_bench",
26795
+ currency: null,
25356
26796
  ranAt: null,
25357
26797
  modelId: null,
25358
26798
  samples: null,
25359
26799
  completed: null,
25360
- p50: null,
25361
- p95: null,
25362
- max: null,
25363
- fitP95: null,
25364
- chargedBasedEstimateCoins: null,
25365
- lengthRaisedPrice: false,
25366
- cutOffSamples: 0,
25367
- noRecommendationReason: null,
25368
- recommendedDeclaredHeadroomBps: null,
25369
- recommendedEstimateCoins: null,
25370
- recommendedPerCallMaxCoins: null,
26800
+ ...creditKeys(null),
26801
+ legacyCoinTerms: null,
25371
26802
  savedTo: null
25372
26803
  });
25373
26804
  } else {
25374
26805
  log.error("Nothing has been benchmarked in this folder yet.");
25375
- log.dim(` Run ${c.cyan('genex llm bench "<prompt>" --max-coins <n> --user-approved')} first.`);
26806
+ log.dim(` Run ${c.cyan(rerun)} first.`);
25376
26807
  }
25377
26808
  process.exitCode = 1;
25378
26809
  return;
25379
26810
  }
26811
+ if (saved.kind === "coins") {
26812
+ const old = saved.record;
26813
+ const legacy = {
26814
+ p50: old.p50 ?? null,
26815
+ p95: old.p95 ?? null,
26816
+ max: old.max ?? null,
26817
+ recommendedEstimateCoins: old.recommendedEstimateCoins ?? null,
26818
+ recommendedPerCallMaxCoins: old.recommendedPerCallMaxCoins ?? null,
26819
+ maxCoinsPerSample: old.maxCoinsPerSample ?? null,
26820
+ noRecommendationReason: old.noRecommendationReason ?? null
26821
+ };
26822
+ if (opts.json) {
26823
+ writeJsonLine({
26824
+ command: "llm price",
26825
+ status: "failed",
26826
+ error: "coin_terms",
26827
+ currency: "coins",
26828
+ ranAt: old.ranAt ?? null,
26829
+ modelId: old.modelId ?? null,
26830
+ samples: old.samples ?? null,
26831
+ completed: old.completed ?? null,
26832
+ ...creditKeys(null),
26833
+ legacyCoinTerms: legacy,
26834
+ savedTo: LLM_BENCH_FILE
26835
+ });
26836
+ } else {
26837
+ log.plain(c.bold("genex llm price"));
26838
+ log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${old.ranAt ?? "at an unknown time"}${old.modelId ? ` on ${old.modelId}` : ""}`);
26839
+ log.plain("");
26840
+ log.warn(` ${LLM_PRICE_COIN_TERMS_LINE}`);
26841
+ const measured = legacy.p95 === null ? "no settled sample" : `charged coins p50 ${legacy.p50} \xB7 p95 ${legacy.p95} \xB7 max ${legacy.max}`;
26842
+ const recommended = legacy.recommendedEstimateCoins === null ? "no price recommended" : `it recommended estimateCoins ${legacy.recommendedEstimateCoins}${legacy.recommendedPerCallMaxCoins != null ? ` and perCallMaxCoins ${legacy.recommendedPerCallMaxCoins}` : ""}`;
26843
+ log.dim(` What it measured then: ${measured}; ${recommended}.`);
26844
+ log.dim(` Re-run: ${c.cyan(rerun)}`);
26845
+ }
26846
+ process.exitCode = 1;
26847
+ return;
26848
+ }
26849
+ const record = saved.record;
25380
26850
  if (opts.json) {
25381
26851
  writeJsonLine({
25382
26852
  command: "llm price",
25383
- status: record.recommendedEstimateCoins === null ? "failed" : "ok",
25384
- error: record.recommendedEstimateCoins === null ? record.noRecommendationReason ?? "no_recommendation" : null,
26853
+ status: record.recommendedMaxCredits === null ? "failed" : "ok",
26854
+ error: record.recommendedMaxCredits === null ? record.noRecommendationReason ?? "no_recommendation" : null,
26855
+ currency: "credits",
25385
26856
  ranAt: record.ranAt ?? null,
25386
26857
  modelId: record.modelId ?? null,
25387
26858
  samples: record.samples ?? null,
25388
26859
  completed: record.completed ?? null,
25389
- p50: record.p50 ?? null,
25390
- p95: record.p95 ?? null,
25391
- max: record.max ?? null,
25392
- // Absent from a file an older CLI wrote; null / false / 0 then, never missing.
25393
- fitP95: record.fitP95 ?? null,
25394
- chargedBasedEstimateCoins: record.chargedBasedEstimateCoins ?? null,
25395
- lengthRaisedPrice: record.lengthRaisedPrice ?? false,
25396
- cutOffSamples: record.cutOffSamples ?? 0,
25397
- noRecommendationReason: record.noRecommendationReason ?? null,
25398
- recommendedDeclaredHeadroomBps: record.recommendedDeclaredHeadroomBps ?? null,
25399
- recommendedEstimateCoins: record.recommendedEstimateCoins ?? null,
25400
- recommendedPerCallMaxCoins: record.recommendedPerCallMaxCoins ?? null,
26860
+ ...creditKeys(record),
26861
+ legacyCoinTerms: null,
25401
26862
  savedTo: LLM_BENCH_FILE
25402
26863
  });
25403
- if (record.recommendedEstimateCoins === null) process.exitCode = 1;
26864
+ if (record.recommendedMaxCredits === null) process.exitCode = 1;
25404
26865
  return;
25405
26866
  }
25406
26867
  log.plain(c.bold("genex llm price"));
25407
26868
  log.dim(` from ${LLM_BENCH_FILE}, benchmarked ${record.ranAt}`);
25408
26869
  log.plain("");
25409
26870
  printRecommendation(log, record);
25410
- if (record.recommendedEstimateCoins === null) process.exitCode = 1;
26871
+ if (record.recommendedMaxCredits === null) process.exitCode = 1;
25411
26872
  }
25412
26873
  function writeJsonLine(value) {
25413
26874
  process.stdout.write(`${JSON.stringify(value)}
@@ -25667,7 +27128,7 @@ function runtimeRow(state, lane, toolsOnly = false) {
25667
27128
  return {
25668
27129
  label: "Runtime",
25669
27130
  value: `in-game LLM lane live \xB7 ${n} model${n === 1 ? "" : "s"}${keyRefused ? ` \xB7 ${c.yellow("provider key refused")}` : ""}`,
25670
- fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Price a call before a game declares one: `npx genex llm bench "<prompt>" --max-coins <n> --user-approved`.'
27131
+ fix: keyRefused ? LLM_PROVIDER_KEY_REJECTED_LINE : toolsOnly ? convertFix : 'Measure a call before a game declares its per-call ceiling: `npx genex llm bench "<prompt>" --max-credits <n> --user-approved`.'
25671
27132
  };
25672
27133
  }
25673
27134
  case "off":
@@ -25727,7 +27188,7 @@ async function fetchLegalStatus(apiUrl, token) {
25727
27188
  }
25728
27189
  async function firstSkillsMarker() {
25729
27190
  for (const target of resolveAgentTargets()) {
25730
- const marker = await readSkillsMarker(path37.join(target.baseDir, "skills"));
27191
+ const marker = await readSkillsMarker(path39.join(target.baseDir, "skills"));
25731
27192
  if (marker) return marker;
25732
27193
  }
25733
27194
  return null;
@@ -25779,11 +27240,11 @@ async function runTools(opts) {
25779
27240
  let totalUpdated = 0;
25780
27241
  const refused = [];
25781
27242
  for (const t of targets) {
25782
- const dest = path38.join(t.baseDir, "skills");
27243
+ const dest = path40.join(t.baseDir, "skills");
25783
27244
  let copied;
25784
27245
  let updated;
25785
27246
  try {
25786
- ({ copied, updated } = await copyTemplates(path38.join(templatesDir, "skills"), dest, {
27247
+ ({ copied, updated } = await copyTemplates(path40.join(templatesDir, "skills"), dest, {
25787
27248
  filter: (rel) => skillFamilyFilter("tools")(`skills/${rel}`)
25788
27249
  }));
25789
27250
  await pruneRemovedSkills(dest, log);
@@ -25855,8 +27316,8 @@ async function runTools(opts) {
25855
27316
  }
25856
27317
 
25857
27318
  // src/commands/budget.ts
25858
- import fs36 from "fs/promises";
25859
- import path39 from "path";
27319
+ import fs39 from "fs/promises";
27320
+ import path41 from "path";
25860
27321
  var BUDGET_SOURCE_LINE = "Live from your account; prices can change without a deploy.";
25861
27322
  var BUDGET_APPROVAL_REQUIRED = "STOP: raising the asset allowance requires the player's explicit approval. Re-run with --user-approved only after they agreed to the number.";
25862
27323
  function internalKind(kind) {
@@ -25864,7 +27325,7 @@ function internalKind(kind) {
25864
27325
  }
25865
27326
  async function isGenexProject(cwd) {
25866
27327
  try {
25867
- await fs36.access(path39.join(cwd, ".genex"));
27328
+ await fs39.access(path41.join(cwd, ".genex"));
25868
27329
  return true;
25869
27330
  } catch {
25870
27331
  return false;
@@ -26013,39 +27474,39 @@ async function runBudget(opts = {}) {
26013
27474
  }
26014
27475
 
26015
27476
  // src/commands/player.ts
26016
- import fs41 from "fs";
27477
+ import fs44 from "fs";
26017
27478
  import fsp2 from "fs/promises";
26018
27479
  import os12 from "os";
26019
- import path45 from "path";
27480
+ import path47 from "path";
26020
27481
  import { spawn as spawn8 } from "child_process";
26021
27482
 
26022
27483
  // src/lib/player/paths.ts
26023
27484
  import os8 from "os";
26024
- import path40 from "path";
27485
+ import path42 from "path";
26025
27486
  var PLAYER_DRY_RUN_ENV = "GENEX_PLAYER_DRY_RUN";
26026
27487
  function playerDryRun() {
26027
27488
  const raw = process.env[PLAYER_DRY_RUN_ENV];
26028
27489
  return raw === "1" || raw === "true";
26029
27490
  }
26030
27491
  function playerPaths(home = os8.homedir()) {
26031
- const dir = home === os8.homedir() ? getGenexDir() : path40.join(home, ".genex");
26032
- const logDir = path40.join(dir, "logs");
27492
+ const dir = home === os8.homedir() ? getGenexDir() : path42.join(home, ".genex");
27493
+ const logDir = path42.join(dir, "logs");
26033
27494
  return {
26034
- credential: path40.join(dir, "player.json"),
26035
- state: path40.join(dir, "player-state.json"),
26036
- relaunch: path40.join(dir, "player-relaunch.json"),
27495
+ credential: path42.join(dir, "player.json"),
27496
+ state: path42.join(dir, "player-state.json"),
27497
+ relaunch: path42.join(dir, "player-relaunch.json"),
26037
27498
  logDir,
26038
- log: path40.join(logDir, "player.log"),
26039
- errorLog: path40.join(logDir, "player.error.log")
27499
+ log: path42.join(logDir, "player.log"),
27500
+ errorLog: path42.join(logDir, "player.error.log")
26040
27501
  };
26041
27502
  }
26042
27503
 
26043
27504
  // src/lib/player/store.ts
26044
- import fs37 from "fs/promises";
26045
- import path41 from "path";
27505
+ import fs40 from "fs/promises";
27506
+ import path43 from "path";
26046
27507
  async function readPlayerInstall(home) {
26047
27508
  try {
26048
- const raw = await fs37.readFile(playerPaths(home).credential, "utf8");
27509
+ const raw = await fs40.readFile(playerPaths(home).credential, "utf8");
26049
27510
  const parsed = JSON.parse(raw);
26050
27511
  if (!parsed || typeof parsed.token !== "string" || typeof parsed.apiUrl !== "string") return null;
26051
27512
  return parsed;
@@ -26075,7 +27536,7 @@ function describeInstall(install2) {
26075
27536
  }
26076
27537
  async function readPlayerState(home) {
26077
27538
  try {
26078
- return JSON.parse(await fs37.readFile(playerPaths(home).state, "utf8"));
27539
+ return JSON.parse(await fs40.readFile(playerPaths(home).state, "utf8"));
26079
27540
  } catch {
26080
27541
  return null;
26081
27542
  }
@@ -26083,8 +27544,8 @@ async function readPlayerState(home) {
26083
27544
  async function writePlayerState(state, home) {
26084
27545
  const file = playerPaths(home).state;
26085
27546
  try {
26086
- await fs37.mkdir(path41.dirname(file), { recursive: true, mode: 448 });
26087
- await fs37.writeFile(file, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
27547
+ await fs40.mkdir(path43.dirname(file), { recursive: true, mode: 448 });
27548
+ await fs40.writeFile(file, JSON.stringify(state, null, 2) + "\n", { mode: 384 });
26088
27549
  } catch {
26089
27550
  }
26090
27551
  }
@@ -26092,8 +27553,8 @@ async function removePlayerFiles(home) {
26092
27553
  const paths = playerPaths(home);
26093
27554
  const removed = [];
26094
27555
  for (const file of [paths.credential, paths.state, paths.relaunch]) {
26095
- const existed = await fs37.stat(file).then(() => true, () => false);
26096
- await fs37.rm(file, { force: true }).catch(() => {
27556
+ const existed = await fs40.stat(file).then(() => true, () => false);
27557
+ await fs40.rm(file, { force: true }).catch(() => {
26097
27558
  });
26098
27559
  if (existed) removed.push(file);
26099
27560
  }
@@ -26227,14 +27688,14 @@ var PlayerClient = class {
26227
27688
  this.version = options.version ?? getCliVersion();
26228
27689
  this.timeoutMs = options.timeoutMs ?? 3e4;
26229
27690
  }
26230
- async call(path47, init = {}, provider) {
27691
+ async call(path49, init = {}, provider) {
26231
27692
  const headers = {
26232
27693
  Authorization: `Bearer ${this.token}`,
26233
27694
  [PLAYER_VERSION_HEADER]: this.version,
26234
27695
  ...provider ? { [EXTERNAL_PROVIDER_HEADER]: provider } : {},
26235
27696
  ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
26236
27697
  };
26237
- const res = await apiFetch(`${this.apiUrl}${path47}`, {
27698
+ const res = await apiFetch(`${this.apiUrl}${path49}`, {
26238
27699
  ...init,
26239
27700
  headers,
26240
27701
  signal: AbortSignal.timeout(this.timeoutMs)
@@ -26303,10 +27764,10 @@ var PlayerClient = class {
26303
27764
 
26304
27765
  // src/lib/player/runners.ts
26305
27766
  import { spawn as spawn6, spawnSync } from "child_process";
26306
- import fs38 from "fs/promises";
27767
+ import fs41 from "fs/promises";
26307
27768
  import fsSync from "fs";
26308
27769
  import os9 from "os";
26309
- import path42 from "path";
27770
+ import path44 from "path";
26310
27771
  import { randomUUID as randomUUID3 } from "crypto";
26311
27772
  var WATCHER_RUNNERS = ["claude", "codex"];
26312
27773
  var RUNNER_FOR_FAMILY = { claude: "claude", chatgpt: "codex" };
@@ -26364,7 +27825,7 @@ function resolveOnPath(name, env = process.env, platform = process.platform) {
26364
27825
  const candidates = win ? [`${name}.cmd`, `${name}.exe`, `${name}.bat`, name] : [name];
26365
27826
  for (const dir of dirs) {
26366
27827
  for (const candidate of candidates) {
26367
- const file = path42.join(dir, candidate);
27828
+ const file = path44.join(dir, candidate);
26368
27829
  try {
26369
27830
  const stat = fsSync.statSync(file);
26370
27831
  if (!stat.isFile()) continue;
@@ -26528,17 +27989,17 @@ async function runJob(job, options = {}) {
26528
27989
  if (!resolved) {
26529
27990
  return { ok: false, release: "runner_unavailable", detail: `missing:${runner}`, durationMs: Date.now() - started };
26530
27991
  }
26531
- const cwd = path42.join(os9.tmpdir(), `genex-player-${randomUUID3()}`);
26532
- await fs38.mkdir(cwd, { recursive: true, mode: 448 });
26533
- const outFile = path42.join(cwd, "result.out");
27992
+ const cwd = path44.join(os9.tmpdir(), `genex-player-${randomUUID3()}`);
27993
+ await fs41.mkdir(cwd, { recursive: true, mode: 448 });
27994
+ const outFile = path44.join(cwd, "result.out");
26534
27995
  let argv;
26535
27996
  if (runner === "claude") {
26536
27997
  argv = claudeArgv(job, policy.claude ?? FALLBACK_POLICY.claude ?? {});
26537
27998
  } else {
26538
27999
  let schemaFile;
26539
28000
  if (job.outputFormat === "json" && job.schema !== void 0) {
26540
- schemaFile = path42.join(cwd, "schema.json");
26541
- await fs38.writeFile(schemaFile, JSON.stringify(job.schema), { mode: 384 });
28001
+ schemaFile = path44.join(cwd, "schema.json");
28002
+ await fs41.writeFile(schemaFile, JSON.stringify(job.schema), { mode: 384 });
26542
28003
  }
26543
28004
  argv = codexArgv(job, policy.codex ?? FALLBACK_POLICY.codex ?? {}, { cwd, outFile, ...schemaFile ? { schemaFile } : {} });
26544
28005
  }
@@ -26602,7 +28063,7 @@ async function runJob(job, options = {}) {
26602
28063
  if (!read2.ok) return { ok: false, release: "failed", detail: read2.detail, exitCode: code, durationMs };
26603
28064
  return { ok: true, output: read2.output, exitCode: code, durationMs };
26604
28065
  }
26605
- const text = await fs38.readFile(outFile, "utf8").catch(() => "");
28066
+ const text = await fs41.readFile(outFile, "utf8").catch(() => "");
26606
28067
  if (code !== 0 && !text.trim()) {
26607
28068
  const classified = classifyFailure(`${stderrText} exit ${code}`);
26608
28069
  return { ok: false, ...classified, detail: `exit_${code}`, exitCode: code, durationMs };
@@ -26611,15 +28072,15 @@ async function runJob(job, options = {}) {
26611
28072
  if (!read.ok) return { ok: false, release: "failed", detail: read.detail, exitCode: code, durationMs };
26612
28073
  return { ok: true, output: read.output, exitCode: code, durationMs };
26613
28074
  } finally {
26614
- await fs38.rm(cwd, { recursive: true, force: true }).catch(() => {
28075
+ await fs41.rm(cwd, { recursive: true, force: true }).catch(() => {
26615
28076
  });
26616
28077
  }
26617
28078
  }
26618
28079
 
26619
28080
  // src/lib/player/service.ts
26620
- import fs39 from "fs/promises";
28081
+ import fs42 from "fs/promises";
26621
28082
  import os10 from "os";
26622
- import path43 from "path";
28083
+ import path45 from "path";
26623
28084
  import { execFile } from "child_process";
26624
28085
  import { promisify } from "util";
26625
28086
  var run2 = promisify(execFile);
@@ -26636,9 +28097,9 @@ function servicePlatform(platform = process.platform) {
26636
28097
  }
26637
28098
  function servicePaths(platform, home = os10.homedir()) {
26638
28099
  const { log, errorLog } = playerPaths(home);
26639
- if (platform === "darwin") return { log, errorLog, unit: path43.join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
26640
- if (platform === "linux") return { log, errorLog, unit: path43.join(home, ".config", "systemd", "user", SYSTEMD_UNIT) };
26641
- return { log, errorLog, unit: path43.join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "genex-player.cmd") };
28100
+ if (platform === "darwin") return { log, errorLog, unit: path45.join(home, "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`) };
28101
+ if (platform === "linux") return { log, errorLog, unit: path45.join(home, ".config", "systemd", "user", SYSTEMD_UNIT) };
28102
+ return { log, errorLog, unit: path45.join(home, "AppData", "Roaming", "Microsoft", "Windows", "Start Menu", "Programs", "Startup", "genex-player.cmd") };
26642
28103
  }
26643
28104
  function xmlEscape(value) {
26644
28105
  return value.replace(/[<>&'"]/g, (ch) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" })[ch]);
@@ -26717,9 +28178,9 @@ function autostartCommand(opts) {
26717
28178
  }
26718
28179
  async function installService(input, platform = servicePlatform()) {
26719
28180
  const paths = servicePaths(platform, input.home);
26720
- await fs39.mkdir(path43.dirname(paths.unit), { recursive: true });
26721
- await fs39.mkdir(path43.dirname(paths.log), { recursive: true, mode: 448 });
26722
- await fs39.writeFile(paths.unit, unitFile(platform, input, paths), { mode: platform === "win32" ? 420 : 384 });
28181
+ await fs42.mkdir(path45.dirname(paths.unit), { recursive: true });
28182
+ await fs42.mkdir(path45.dirname(paths.log), { recursive: true, mode: 448 });
28183
+ await fs42.writeFile(paths.unit, unitFile(platform, input, paths), { mode: platform === "win32" ? 420 : 384 });
26723
28184
  if (playerDryRun()) return { unit: paths.unit, loaded: false, detail: "dry run \u2014 the unit was written, the system was not asked" };
26724
28185
  if (platform === "darwin") {
26725
28186
  const domain = `gui/${process.getuid?.() ?? 501}`;
@@ -26748,7 +28209,7 @@ async function installService(input, platform = servicePlatform()) {
26748
28209
  }
26749
28210
  async function serviceInstalled(home = os10.homedir(), platform = servicePlatform()) {
26750
28211
  const paths = servicePaths(platform, home);
26751
- return await fs39.stat(paths.unit).then(() => paths.unit, () => null);
28212
+ return await fs42.stat(paths.unit).then(() => paths.unit, () => null);
26752
28213
  }
26753
28214
  async function stopService(home = os10.homedir(), platform = servicePlatform()) {
26754
28215
  const paths = servicePaths(platform, home);
@@ -26778,15 +28239,15 @@ async function uninstallService(home = os10.homedir(), platform = servicePlatfor
26778
28239
  });
26779
28240
  }
26780
28241
  }
26781
- await fs39.rm(paths.unit, { force: true }).catch(() => {
28242
+ await fs42.rm(paths.unit, { force: true }).catch(() => {
26782
28243
  });
26783
28244
  return { unit: paths.unit, loaded: false, detail: present ? "removed" : "nothing installed" };
26784
28245
  }
26785
28246
 
26786
28247
  // src/lib/player/relaunch.ts
26787
- import fs40 from "fs/promises";
28248
+ import fs43 from "fs/promises";
26788
28249
  import fsSync2 from "fs";
26789
- import path44 from "path";
28250
+ import path46 from "path";
26790
28251
  import { spawn as spawn7 } from "child_process";
26791
28252
  var RELAUNCH_COOLDOWN_MS = 10 * 60 * 1e3;
26792
28253
  function relaunchPlan(input) {
@@ -26814,7 +28275,7 @@ function relaunchPossible() {
26814
28275
  }
26815
28276
  async function relaunchOnCooldown(home, now = Date.now()) {
26816
28277
  try {
26817
- const raw = await fs40.readFile(playerPaths(home).relaunch, "utf8");
28278
+ const raw = await fs43.readFile(playerPaths(home).relaunch, "utf8");
26818
28279
  const at = Date.parse(JSON.parse(raw).at ?? "");
26819
28280
  return Number.isFinite(at) && now - at < RELAUNCH_COOLDOWN_MS;
26820
28281
  } catch {
@@ -26824,8 +28285,8 @@ async function relaunchOnCooldown(home, now = Date.now()) {
26824
28285
  async function markRelaunch(version, home) {
26825
28286
  const file = playerPaths(home).relaunch;
26826
28287
  try {
26827
- await fs40.mkdir(path44.dirname(file), { recursive: true, mode: 448 });
26828
- await fs40.writeFile(file, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), version }) + "\n", { mode: 384 });
28288
+ await fs43.mkdir(path46.dirname(file), { recursive: true, mode: 448 });
28289
+ await fs43.writeFile(file, JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), version }) + "\n", { mode: 384 });
26829
28290
  } catch {
26830
28291
  }
26831
28292
  }
@@ -26849,7 +28310,7 @@ async function relaunchWatcher(input) {
26849
28310
  let err;
26850
28311
  const paths = playerPaths(input.home);
26851
28312
  try {
26852
- await fs40.mkdir(paths.logDir, { recursive: true, mode: 448 });
28313
+ await fs43.mkdir(paths.logDir, { recursive: true, mode: 448 });
26853
28314
  out = fsSync2.openSync(paths.log, "a", 384);
26854
28315
  err = fsSync2.openSync(paths.errorLog, "a", 384);
26855
28316
  } catch {
@@ -27187,15 +28648,15 @@ function defaultLabel2() {
27187
28648
  }
27188
28649
 
27189
28650
  // src/commands/player.ts
27190
- var SUBS4 = ["install", "run", "status", "stop", "uninstall"];
28651
+ var SUBS5 = ["install", "run", "status", "stop", "uninstall"];
27191
28652
  var PLAYER_NOT_INSTALLED = "This machine is not set up to answer in-game model requests \u2014 run `genex player install` first.";
27192
28653
  var PLAYER_REAUTH_REQUIRED = "Genex no longer accepts this machine's approval \u2014 run `genex player install` again.";
27193
28654
  var PLAYER_NO_RUNNERS = "Neither `claude` nor `codex` is on this machine's PATH, so nothing can be answered yet. Install the one whose subscription you pay for, sign into it, and this watcher picks it up on its own.";
27194
28655
  async function runPlayer(opts = {}) {
27195
28656
  const log = createLogger({ quiet: opts.quiet || opts.json });
27196
28657
  const sub = opts.name?.trim() || "status";
27197
- if (!SUBS4.includes(sub)) {
27198
- fail4(opts, log, `player ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS4.join(", ")}.`);
28658
+ if (!SUBS5.includes(sub)) {
28659
+ fail4(opts, log, `player ${sub}`, `Unknown subcommand ${sub}. Use: ${SUBS5.join(", ")}.`);
27199
28660
  return;
27200
28661
  }
27201
28662
  if (opts.dryRun) process.env[PLAYER_DRY_RUN_ENV] = "1";
@@ -27330,8 +28791,8 @@ async function spawnDetachedRun(apiUrl, opts) {
27330
28791
  try {
27331
28792
  const paths = playerPaths();
27332
28793
  await fsp2.mkdir(paths.logDir, { recursive: true, mode: 448 });
27333
- out = fs41.openSync(paths.log, "a", 384);
27334
- err = fs41.openSync(paths.errorLog, "a", 384);
28794
+ out = fs44.openSync(paths.log, "a", 384);
28795
+ err = fs44.openSync(paths.errorLog, "a", 384);
27335
28796
  const args = [process.argv[1] ?? "", "player", "run", ...opts.apiUrl ? ["--api-url", apiUrl] : []];
27336
28797
  const child = spawn8(process.execPath, args, { detached: true, stdio: ["ignore", out, err] });
27337
28798
  child.on("error", () => {
@@ -27342,7 +28803,7 @@ async function spawnDetachedRun(apiUrl, opts) {
27342
28803
  return false;
27343
28804
  } finally {
27344
28805
  for (const fd of [out, err]) if (fd !== void 0) try {
27345
- fs41.closeSync(fd);
28806
+ fs44.closeSync(fd);
27346
28807
  } catch {
27347
28808
  }
27348
28809
  }
@@ -27541,7 +29002,7 @@ async function uninstall(opts, log) {
27541
29002
  writeJsonLine2({ command: "player uninstall", status: "ok", error: null, unit: service.unit, detail: service.detail, removed });
27542
29003
  return;
27543
29004
  }
27544
- log.success(`Removed: ${service.detail} (${path45.basename(service.unit)})`);
29005
+ log.success(`Removed: ${service.detail} (${path47.basename(service.unit)})`);
27545
29006
  for (const file of removed) log.dim(` deleted ${file}`);
27546
29007
  if (removed.length === 0) log.dim(" no credential was stored here");
27547
29008
  log.plain("");
@@ -27559,46 +29020,46 @@ function writeJsonLine2(value) {
27559
29020
  }
27560
29021
 
27561
29022
  // src/lib/native-build.ts
27562
- import { createHash as createHash3 } from "crypto";
29023
+ import { createHash as createHash4 } from "crypto";
27563
29024
  import { createReadStream } from "fs";
27564
- import fs43 from "fs/promises";
29025
+ import fs46 from "fs/promises";
27565
29026
  import os13 from "os";
27566
- import path46 from "path";
29027
+ import path48 from "path";
27567
29028
  import { execFile as execFile2 } from "child_process";
27568
29029
  import { promisify as promisify2 } from "util";
27569
29030
 
27570
29031
  // src/lib/native-zip.ts
27571
- import fs42 from "fs/promises";
27572
- import zlib2 from "zlib";
27573
- var EOCD_SIGNATURE = 101010256;
29032
+ import fs45 from "fs/promises";
29033
+ import zlib3 from "zlib";
29034
+ var EOCD_SIGNATURE2 = 101010256;
27574
29035
  var EOCD64_LOCATOR_SIGNATURE = 117853008;
27575
29036
  var EOCD64_SIGNATURE = 101075792;
27576
- var CENTRAL_SIGNATURE = 33639248;
27577
- var LOCAL_SIGNATURE = 67324752;
29037
+ var CENTRAL_SIGNATURE2 = 33639248;
29038
+ var LOCAL_SIGNATURE2 = 67324752;
27578
29039
  var EOCD_MIN = 22;
27579
- var COMMENT_MAX = 65535;
29040
+ var COMMENT_MAX2 = 65535;
27580
29041
  var ZIP64_EXTRA = 1;
27581
29042
  var U32_MAX = 4294967295;
27582
29043
  var U16_MAX = 65535;
27583
29044
  var DIRECTORY_MAX_BYTES = 256 * 1024 * 1024;
27584
- var UNIX_HOST = 3;
29045
+ var UNIX_HOST2 = 3;
27585
29046
  var INPUT_MIN = 64 * 1024;
27586
29047
  var INPUT_MAX = 1024 * 1024;
27587
- var S_IFMT = 61440;
29048
+ var S_IFMT2 = 61440;
27588
29049
  var S_IFLNK = 40960;
27589
29050
  var isDirectory = (entry) => entry.name.endsWith("/");
27590
- var isSymlink = (entry) => entry.mode !== null && (entry.mode & S_IFMT) === S_IFLNK;
29051
+ var isSymlink = (entry) => entry.mode !== null && (entry.mode & S_IFMT2) === S_IFLNK;
27591
29052
  async function readAt2(file, position, length) {
27592
29053
  const buffer = Buffer.alloc(length);
27593
29054
  const { bytesRead } = await file.read(buffer, 0, length, position);
27594
29055
  return buffer.subarray(0, bytesRead);
27595
29056
  }
27596
- async function findDirectory(file, size) {
27597
- const tailLength = Math.min(size, EOCD_MIN + COMMENT_MAX);
29057
+ async function findDirectory2(file, size) {
29058
+ const tailLength = Math.min(size, EOCD_MIN + COMMENT_MAX2);
27598
29059
  const tail = await readAt2(file, size - tailLength, tailLength);
27599
29060
  let at = -1;
27600
29061
  for (let i = tail.length - EOCD_MIN; i >= 0; i--) {
27601
- if (tail.readUInt32LE(i) === EOCD_SIGNATURE) {
29062
+ if (tail.readUInt32LE(i) === EOCD_SIGNATURE2) {
27602
29063
  at = i;
27603
29064
  break;
27604
29065
  }
@@ -27640,15 +29101,15 @@ function applyZip64(extra, entry, overflowed) {
27640
29101
  }
27641
29102
  }
27642
29103
  async function readZipEntries(filePath) {
27643
- const file = await fs42.open(filePath, "r");
29104
+ const file = await fs45.open(filePath, "r");
27644
29105
  try {
27645
29106
  const { size } = await file.stat();
27646
- const directory = await findDirectory(file, size);
29107
+ const directory = await findDirectory2(file, size);
27647
29108
  const data = await readAt2(file, directory.offset, directory.bytes);
27648
29109
  const entries = [];
27649
29110
  let at = 0;
27650
29111
  while (entries.length < directory.count && at + 46 <= data.length) {
27651
- if (data.readUInt32LE(at) !== CENTRAL_SIGNATURE) throw new Error("broken zip directory entry");
29112
+ if (data.readUInt32LE(at) !== CENTRAL_SIGNATURE2) throw new Error("broken zip directory entry");
27652
29113
  const madeBy = data.readUInt16LE(at + 4);
27653
29114
  const nameLength = data.readUInt16LE(at + 28);
27654
29115
  const extraLength = data.readUInt16LE(at + 30);
@@ -27660,7 +29121,7 @@ async function readZipEntries(filePath) {
27660
29121
  compressedSize: data.readUInt32LE(at + 20),
27661
29122
  uncompressedSize: data.readUInt32LE(at + 24),
27662
29123
  localOffset: data.readUInt32LE(at + 42),
27663
- mode: madeBy >> 8 === UNIX_HOST ? external >>> 16 : null
29124
+ mode: madeBy >> 8 === UNIX_HOST2 ? external >>> 16 : null
27664
29125
  };
27665
29126
  applyZip64(data.subarray(at + 46 + nameLength, at + 46 + nameLength + extraLength), entry, {
27666
29127
  uncompressed: entry.uncompressedSize === U32_MAX,
@@ -27677,10 +29138,10 @@ async function readZipEntries(filePath) {
27677
29138
  }
27678
29139
  }
27679
29140
  async function readZipEntryHead(filePath, entry, maxBytes) {
27680
- const file = await fs42.open(filePath, "r");
29141
+ const file = await fs45.open(filePath, "r");
27681
29142
  try {
27682
29143
  const local = await readAt2(file, entry.localOffset, 30);
27683
- if (local.length < 30 || local.readUInt32LE(0) !== LOCAL_SIGNATURE) throw new Error(`broken zip entry ${entry.name}`);
29144
+ if (local.length < 30 || local.readUInt32LE(0) !== LOCAL_SIGNATURE2) throw new Error(`broken zip entry ${entry.name}`);
27684
29145
  const start = entry.localOffset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
27685
29146
  if (entry.method === 0) return await readAt2(file, start, Math.min(maxBytes, entry.uncompressedSize));
27686
29147
  if (entry.method !== 8) throw new Error(`${entry.name} uses a compression this command cannot read`);
@@ -27692,7 +29153,7 @@ async function readZipEntryHead(filePath, entry, maxBytes) {
27692
29153
  }
27693
29154
  function inflateHead(input, maxBytes) {
27694
29155
  return new Promise((resolve) => {
27695
- const inflater = zlib2.createInflateRaw();
29156
+ const inflater = zlib3.createInflateRaw();
27696
29157
  const chunks = [];
27697
29158
  let total = 0;
27698
29159
  const finish2 = () => resolve(Buffer.concat(chunks).subarray(0, maxBytes));
@@ -27711,7 +29172,7 @@ function inflateHead(input, maxBytes) {
27711
29172
  var run4 = promisify2(execFile2);
27712
29173
  var NATIVE_PLATFORMS = ["mac", "windows", "linux"];
27713
29174
  var NATIVE_ARCHES = ["arm64", "x64", "universal"];
27714
- var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
29175
+ var VERSION2 = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
27715
29176
  var VERSION_MAX = 64;
27716
29177
  var OS_VERSION = /^\d{1,4}(\.\d{1,6}){0,3}$/;
27717
29178
  var ENGINE = /^[a-z][a-z0-9-]{1,39}$/;
@@ -27741,7 +29202,7 @@ function rootApps(entries) {
27741
29202
  }
27742
29203
  function launchableExes(entries) {
27743
29204
  const exes = entries.filter((e) => !isDirectory(e) && e.name.toLowerCase().endsWith(".exe"));
27744
- const games = exes.filter((e) => e.name.split("/").length <= ENTRY_DEPTH_MAX && !HELPER_EXE.test(path46.posix.basename(e.name)));
29205
+ const games = exes.filter((e) => e.name.split("/").length <= ENTRY_DEPTH_MAX && !HELPER_EXE.test(path48.posix.basename(e.name)));
27745
29206
  const depth = (name) => name.split("/").length;
27746
29207
  return games.map((e) => e.name).sort((a, b) => depth(a) - depth(b) || a.localeCompare(b));
27747
29208
  }
@@ -27823,7 +29284,7 @@ function exeArch(head) {
27823
29284
  }
27824
29285
  function engineOf(entries) {
27825
29286
  const names2 = entries.map((e) => e.name.toLowerCase());
27826
- const base = (name) => path46.posix.basename(name.replace(/\/$/, ""));
29287
+ const base = (name) => path48.posix.basename(name.replace(/\/$/, ""));
27827
29288
  if (names2.some((n) => /^unityplayer\.(dll|dylib|so)$/.test(base(n)) || base(n) === "globalgamemanagers")) return "unity";
27828
29289
  if (names2.some((n) => n.startsWith("engine/binaries/") || /\.app\/contents\/ue[45]?\//.test(n) || n.includes("/engine/binaries/")))
27829
29290
  return "unreal";
@@ -27867,7 +29328,7 @@ async function describeNativeArchive(zipPath, asked = {}) {
27867
29328
  try {
27868
29329
  entries = await readZipEntries(zipPath);
27869
29330
  } catch (err) {
27870
- throw new NativeBuildError(`Couldn't read ${path46.basename(zipPath)} as a zip: ${err.message}`);
29331
+ throw new NativeBuildError(`Couldn't read ${path48.basename(zipPath)} as a zip: ${err.message}`);
27871
29332
  }
27872
29333
  if (entries.length === 0) throw new NativeBuildError("The archive is empty.");
27873
29334
  const platform = platformOf(entries, asked.platform);
@@ -27889,19 +29350,19 @@ async function describeNativeArchive(zipPath, asked = {}) {
27889
29350
  ...mac?.appVersion ? { appVersion: mac.appVersion } : {}
27890
29351
  };
27891
29352
  }
27892
- function buildVersion(asked, appVersion, sha2562) {
29353
+ function buildVersion(asked, appVersion, sha2563) {
27893
29354
  if (asked !== void 0) {
27894
- if (asked.length > VERSION_MAX || !VERSION.test(asked)) {
29355
+ if (asked.length > VERSION_MAX || !VERSION2.test(asked)) {
27895
29356
  throw new NativeBuildError(`--build-version "${asked}" may hold only letters, digits and . _ + - (at most ${VERSION_MAX}).`);
27896
29357
  }
27897
29358
  return asked;
27898
29359
  }
27899
- const hash = sha2562.slice(0, VERSION_SHA_CHARS);
29360
+ const hash = sha2563.slice(0, VERSION_SHA_CHARS);
27900
29361
  const app = (appVersion ?? "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, VERSION_MAX - hash.length - 1);
27901
29362
  return app ? `${app}+${hash}` : hash;
27902
29363
  }
27903
29364
  async function hashFile(filePath, onProgress) {
27904
- const hash = createHash3("sha256");
29365
+ const hash = createHash4("sha256");
27905
29366
  let sizeBytes = 0;
27906
29367
  for await (const chunk of createReadStream(filePath)) {
27907
29368
  hash.update(chunk);
@@ -27912,13 +29373,13 @@ async function hashFile(filePath, onProgress) {
27912
29373
  }
27913
29374
  async function packTarget(dir) {
27914
29375
  if (dir.toLowerCase().endsWith(".app")) return { target: dir, keepParent: true };
27915
- const children = await fs43.readdir(dir, { withFileTypes: true });
29376
+ const children = await fs46.readdir(dir, { withFileTypes: true });
27916
29377
  const apps = children.filter((d) => d.isDirectory() && d.name.toLowerCase().endsWith(".app"));
27917
- if (apps.length === 1) return { target: path46.join(dir, apps[0].name), keepParent: true };
29378
+ if (apps.length === 1) return { target: path48.join(dir, apps[0].name), keepParent: true };
27918
29379
  return { target: dir, keepParent: false };
27919
29380
  }
27920
29381
  async function packNativeBuild(input) {
27921
- const stat = await fs43.stat(input).catch(() => null);
29382
+ const stat = await fs46.stat(input).catch(() => null);
27922
29383
  if (!stat) throw new NativeBuildError(`${input} does not exist.`);
27923
29384
  if (stat.isFile()) {
27924
29385
  if (!input.toLowerCase().endsWith(".zip")) throw new NativeBuildError("Pass a .zip, a .app, or the build's folder.");
@@ -27926,14 +29387,14 @@ async function packNativeBuild(input) {
27926
29387
  } };
27927
29388
  }
27928
29389
  const { target, keepParent } = await packTarget(input);
27929
- const dir = await fs43.mkdtemp(path46.join(os13.tmpdir(), "genex-native-"));
27930
- const zipPath = path46.join(dir, `${path46.basename(target).replace(/[^A-Za-z0-9._-]+/g, "-") || "build"}.zip`);
27931
- const cleanup = () => fs43.rm(dir, { recursive: true, force: true });
29390
+ const dir = await fs46.mkdtemp(path48.join(os13.tmpdir(), "genex-native-"));
29391
+ const zipPath = path48.join(dir, `${path48.basename(target).replace(/[^A-Za-z0-9._-]+/g, "-") || "build"}.zip`);
29392
+ const cleanup = () => fs46.rm(dir, { recursive: true, force: true });
27932
29393
  try {
27933
29394
  if (process.platform === "darwin") {
27934
29395
  await run4("ditto", ["-c", "-k", "--sequesterRsrc", ...keepParent ? ["--keepParent"] : [], target, zipPath]);
27935
29396
  } else if (keepParent) {
27936
- await run4("zip", ["-q", "-r", "-y", zipPath, path46.basename(target)], { cwd: path46.dirname(target) });
29397
+ await run4("zip", ["-q", "-r", "-y", zipPath, path48.basename(target)], { cwd: path48.dirname(target) });
27937
29398
  } else {
27938
29399
  await run4("zip", ["-q", "-r", "-y", zipPath, "."], { cwd: target });
27939
29400
  }
@@ -27948,7 +29409,7 @@ async function packNativeBuild(input) {
27948
29409
  }
27949
29410
 
27950
29411
  // src/lib/native-upload.ts
27951
- import fs44 from "fs/promises";
29412
+ import fs47 from "fs/promises";
27952
29413
  var PART_CONCURRENCY = 2;
27953
29414
  async function call(ctx, route, init, what, log) {
27954
29415
  let res;
@@ -27994,7 +29455,7 @@ async function uploadNativeArchive(grant, zipPath, sizeBytes, log, onProgress) {
27994
29455
  const base = grant.uploadUrl.replace(/\/+$/, "");
27995
29456
  const encoded = grant.uploadPath.split("/").map(encodeURIComponent).join("/");
27996
29457
  const auth = { Authorization: `Bearer ${grant.token}` };
27997
- const file = await fs44.open(zipPath, "r");
29458
+ const file = await fs47.open(zipPath, "r");
27998
29459
  try {
27999
29460
  if (sizeBytes <= grant.limits.singlePutMaxBytes) {
28000
29461
  const body = await readPart(file, 0, sizeBytes);
@@ -28173,10 +29634,10 @@ async function runPublish2(opts, log) {
28173
29634
  const description = await describeNativeArchive(packed.zipPath, opts);
28174
29635
  log.step(describeLine(description));
28175
29636
  log.step("Hashing the archive\u2026");
28176
- const { sha256: sha2562, sizeBytes } = await hashFile(packed.zipPath);
28177
- const version = buildVersion(opts.buildVersion, description.appVersion, sha2562);
29637
+ const { sha256: sha2563, sizeBytes } = await hashFile(packed.zipPath);
29638
+ const version = buildVersion(opts.buildVersion, description.appVersion, sha2563);
28178
29639
  const { platform, arch, entry, installedBytes, minOs, engine } = description;
28179
- const grant = await requestNativeUpload(ctx, { platform, arch, sha256: sha2562, sizeBytes }, log);
29640
+ const grant = await requestNativeUpload(ctx, { platform, arch, sha256: sha2563, sizeBytes }, log);
28180
29641
  if (!grant) return false;
28181
29642
  if (grant.uploaded) {
28182
29643
  log.step(`Already uploaded (${sizeLine(sizeBytes)}).`);
@@ -28187,7 +29648,7 @@ async function runPublish2(opts, log) {
28187
29648
  }
28188
29649
  const build = await publishNativeBuild(
28189
29650
  ctx,
28190
- { platform, arch, sha256: sha2562, sizeBytes, installedBytes, version, entry, ...minOs ? { minOs } : {}, ...engine ? { engine } : {} },
29651
+ { platform, arch, sha256: sha2563, sizeBytes, installedBytes, version, entry, ...minOs ? { minOs } : {}, ...engine ? { engine } : {} },
28191
29652
  log
28192
29653
  );
28193
29654
  if (!build) return false;
@@ -28344,16 +29805,16 @@ ${c.bold("Usage")}
28344
29805
  status | cancel. "models" says whether this
28345
29806
  stand serves the runtime LLM lane at all, and
28346
29807
  at what provider rates. "bench" runs the real
28347
- model on YOUR OWN coin and reports what each
28348
- attempt was charged; it needs --max-coins <n>
29808
+ model on YOUR OWN credits and reports what each
29809
+ attempt really cost; it needs --max-credits <n>
28349
29810
  --user-approved, like any spend the player has
28350
29811
  to agree to. "price" reprints the last run's
28351
- recommended estimateCoins. "status" lists this
29812
+ recommended maxCredits. "status" lists this
28352
29813
  project's open calls and the slots they hold;
28353
29814
  "cancel <id>" stops an active one. Declare a
28354
- game's price from a bench, never from a guess:
28355
- a started attempt is charged in full, including
28356
- one that fails.
29815
+ game's per-call ceiling from a bench, never
29816
+ from a guess: the player is billed as used, and
29817
+ the ceiling also sizes how long an answer may be.
28357
29818
  genex player <sub> [options] Answer the in-game model requests you approve, on your
28358
29819
  OWN Claude or ChatGPT subscription: install | run |
28359
29820
  status | stop | uninstall. "install" signs this machine
@@ -28437,6 +29898,25 @@ ${c.bold("Usage")}
28437
29898
  --resellable lets players resell the item to each
28438
29899
  other on the market; durable items only, off by
28439
29900
  default, and --no-resellable turns it back off.
29901
+ Store items: --price 0 is Free; --description,
29902
+ --tag <word> (repeat; "set <id> --clear-tags"
29903
+ empties them), --image <file.png|webp>
29904
+ (its transparent picture), --model <file.glb>,
29905
+ --package <dir> (files for other games' agents;
29906
+ .git, node_modules, .genex, secrets and links
29907
+ stay out) and --shared ("other games can use
29908
+ it"; durable only). With the store on, "add"
29909
+ makes a draft and --json prints it as one
29910
+ document (a refusal: one genex.item_error
29911
+ line, exit 1). "publish <id>" puts it on sale and
29912
+ "unpublish <id>" takes it off \u2014 in the Genex
29913
+ app the person presses Put on sale instead.
29914
+ genex items <sub> [words|id] Other creators' shared items in this game:
29915
+ search | add | remove | list. "add <id>" lets
29916
+ players use it here (free, not a purchase) and
29917
+ writes genex-items/<id>/ \u2014 item.json and the
29918
+ author's package \u2014 for your agent to integrate.
29919
+ It reaches the store with the next live build.
28440
29920
  genex model "<prompt>" [options] Generate a 3D model (GLB); prints a public asset URL.
28441
29921
  --image <path|url> builds it FROM a reference
28442
29922
  image instead (prompt optional). Quality knobs:
@@ -28821,9 +30301,13 @@ ${c.bold("Examples")}
28821
30301
  genex shop set sku_123 --icon https://assets.genex.technology/generations/abc123/image-main
28822
30302
  genex shop test sku_123
28823
30303
  genex shop remove sku_123
30304
+ genex shop add "Frost Axe" --price 1200 --type durable --description "Freezes on hit." --tag axe --image .genex/scratch/axe.png --shared --json
30305
+ genex shop publish sku_123
30306
+ genex items search "wings" --tag fire
30307
+ genex items add sku_456
28824
30308
  genex llm models
28825
30309
  genex llm models --all
28826
- genex llm bench "Reply with one short taunt." --samples 5 --max-coins 20 --user-approved
30310
+ genex llm bench "Reply with one short taunt." --samples 5 --max-credits 20 --user-approved
28827
30311
  genex llm price
28828
30312
  genex llm status
28829
30313
  genex llm cancel <id>
@@ -29060,22 +30544,24 @@ ${c.bold("How the allowance works")}
29060
30544
 
29061
30545
  Every number printed is live from your account; prices can change without a deploy.
29062
30546
  `,
29063
- llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what should the game declare?
30547
+ llm: `${c.bold("genex llm")} \u2014 in-game model calls: is the lane live, what does one call cost, what ceiling should the game declare?
29064
30548
 
29065
30549
  ${c.bold("Usage")}
29066
30550
  genex llm models [--all] [--json] Which models this stand serves, their provider rates per
29067
- million tokens, and the headroom it recommends over a
29068
- benchmark. The catalog is synced from OpenRouter, so the
29069
- FEATURED short list is printed by default and --all
29070
- prints every row (--json always carries every row). Off
29071
- on this stand: it says so and exits 0 \u2014 build the feature
29072
- without a model rather than promising a 404.
29073
- genex llm bench "<prompt>" --max-coins <n> --user-approved [options]
29074
- Run the real model <n> times through the development
29075
- lane and report what each attempt was CHARGED. Refused
29076
- without both flags, before any network call: this spends
29077
- YOUR OWN COIN, a wallet the build's asset allowance
29078
- cannot see. Needs a folder linked to a game you own.
30551
+ million tokens, the platform fee a call is billed at, and
30552
+ the headroom it recommends over a benchmark. The catalog
30553
+ is synced from OpenRouter, so the FEATURED short list is
30554
+ printed by default and --all prints every row (--json
30555
+ always carries every row). Off on this stand: it says so
30556
+ and exits 0 \u2014 build the feature without a model rather
30557
+ than promising a 404.
30558
+ genex llm bench "<prompt>" --max-credits <n> --user-approved [options]
30559
+ Run the real model through the development lane and
30560
+ report what each attempt really COST and was charged.
30561
+ Refused without both flags, before any network call:
30562
+ this spends YOUR OWN CREDITS, a spend the build's asset
30563
+ allowance does not count. Needs a folder linked to a
30564
+ game you own, and a balance that covers one attempt.
29079
30565
  genex llm price [--json] The recommendation from the last bench in this folder.
29080
30566
  Reads a file; spends nothing.
29081
30567
  genex llm status [--json] This project's open development calls: active, or
@@ -29092,29 +30578,32 @@ ${c.bold("Bench options")}
29092
30578
  --samples <n> How many real attempts (default ${DEFAULT_BENCH_SAMPLES}). More samples, better p95.
29093
30579
  --schema <file> A JSON file holding the output schema; implies JSON output.
29094
30580
  --json-output Ask for JSON without a schema. --text Ask for text (the default).
29095
- --max-coins <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own coin,
30581
+ --max-credits <n> The per-attempt ceiling YOU approved \u2014 a bench runs on your own credits,
29096
30582
  never a player's. The run's worst case is that number times --samples,
29097
- and it is printed before anything starts.
30583
+ and it is printed before anything starts. (--max-coins is its deprecated
30584
+ name, read as the same number.)
29098
30585
  --timeout <seconds> Bounds the wait for each attempt.
29099
30586
  --json One machine-readable object; every key is always present, null when
29100
30587
  the CLI could not learn it.
29101
30588
 
29102
30589
  ${c.bold("Why a benchmark and not an estimate")}
29103
- \`estimateCoins\` is a PRICE, not a guess: declaring it charges it, in full, even when the
29104
- attempt fails, is cancelled, or stops at its budget. And three layers sit between the
29105
- provider's rate card and that number \u2014 the provider's cost, the platform's tariff (already
29106
- inside every charged coin), and your headroom. The bench itself is billed to YOU, on the
29107
- development lane; the player-funded lane is what the published game uses. Adding a margin
29108
- to the provider's rate silently drops the tariff and under-prices every call, so the
29109
- recommendation is built from CHARGED COINS and the headroom comes from the server. A stand
29110
- that serves no headroom gets no recommendation, because the multiplier is not the CLI's to
29111
- invent.
30590
+ An in-game call is paid from the PLAYER's credits and billed as used: its real provider cost
30591
+ plus the platform fee, never more than the per-call CEILING the game declares (\`maxCredits\`,
30592
+ or \`perCallMaxCredits\` on a standing budget). The ceiling is not a price, but it is still a
30593
+ number with two jobs: it bounds what one call may cost, and it funds how LONG the answer may
30594
+ be. Three layers sit between the provider's rate card and it \u2014 the provider's cost for YOUR
30595
+ prompt, the platform fee, and your headroom \u2014 and only a real call measures the first. The
30596
+ bench is billed to YOU, on the development lane; the player-funded lane is what the
30597
+ published game uses. The recommendation is the p95 real cost with the server's fee and
30598
+ headroom applied, in whole credits; a stand that serves no headroom or no fee gets no
30599
+ recommendation, because those multipliers are not the CLI's to invent.
29112
30600
 
29113
- The declared price also decides how LONG the answer may be: it sizes each call's output
29114
- allowance. So the recommendation is never below the smallest price that leaves room for
29115
- the benchmarked answer, as the server computes it. A sample cut off at your --max-coins
29116
- (provider_token_limit) is not a sample \u2014 re-run with a higher --max-coins; an answer longer
29117
- than one call on this stand may produce gets no price at all \u2014 ask for a shorter one.
30601
+ Because the ceiling sizes each call's output allowance, the recommendation is never below
30602
+ the smallest ceiling that leaves room for the benchmarked answer, as the server computes it.
30603
+ A sample cut off at your --max-credits (provider_token_limit) is not a sample \u2014 re-run with a
30604
+ higher --max-credits; an answer longer than one call on this stand may produce gets no
30605
+ ceiling at all \u2014 ask for a shorter one. The run also prints the measured average per call,
30606
+ fee included, which is the honest estimate a budget's disclosure is built on.
29118
30607
 
29119
30608
  Results land in ${LLM_BENCH_FILE} \u2014 gitignored, machine-local, re-read by \`genex llm price\`.
29120
30609
  `,
@@ -29298,6 +30787,10 @@ function parseArgs(argv) {
29298
30787
  "--operation-id",
29299
30788
  "--price",
29300
30789
  "--icon",
30790
+ // `genex shop add|set` and `genex items search`: one search word per --tag.
30791
+ "--tag",
30792
+ // `genex shop add|set --package <dir>`: the item's files for other games' agents.
30793
+ "--package",
29301
30794
  "--rename",
29302
30795
  "--type",
29303
30796
  "--title",
@@ -29365,11 +30858,13 @@ function parseArgs(argv) {
29365
30858
  "--limit",
29366
30859
  // `genex budget --assets <credits>` — the allowance the player approved.
29367
30860
  "--assets",
29368
- // `genex llm bench` value flags. `--max-coins` is the COIN half of the
29369
- // same approval shape as `--assets` (a different economy, its own gate).
30861
+ // `genex llm bench` value flags. `--max-credits` is the same approval
30862
+ // shape as `--assets`, but its own gate: the asset allowance does not
30863
+ // count a bench. `--max-coins` is its deprecated name (same number).
29370
30864
  "--model",
29371
30865
  "--samples",
29372
30866
  "--schema",
30867
+ "--max-credits",
29373
30868
  "--max-coins",
29374
30869
  // `genex player install --label <name>` — what this machine is called in
29375
30870
  // the dashboard. Its own key rather than the generic 2nd positional,
@@ -29563,6 +31058,19 @@ function parseArgs(argv) {
29563
31058
  case "--no-resellable":
29564
31059
  parsed.options.resellable = false;
29565
31060
  break;
31061
+ // `genex shop add|set`: "Other games can use it" (P10). A pair for the
31062
+ // same reason as --resellable: `set` must tell OFF from ABSENT.
31063
+ case "--shared":
31064
+ parsed.options.shared = true;
31065
+ break;
31066
+ case "--no-shared":
31067
+ parsed.options.shared = false;
31068
+ break;
31069
+ // `genex shop set <id> --clear-tags`: --tag replaces the list and an
31070
+ // empty --tag is refused, so emptying it needs its own flag.
31071
+ case "--clear-tags":
31072
+ parsed.options.clearTags = true;
31073
+ break;
29566
31074
  case "--list":
29567
31075
  parsed.options.list = true;
29568
31076
  break;
@@ -29662,6 +31170,8 @@ function parseArgs(argv) {
29662
31170
  parsed.error = `Unexpected argument: ${arg}`;
29663
31171
  return parsed;
29664
31172
  }
31173
+ } else if (parsed.command === "items") {
31174
+ parsed.options.query = parsed.options.query ? `${parsed.options.query} ${arg}` : arg;
29665
31175
  } else if (parsed.command === "domain") {
29666
31176
  if (!parsed.options.hostname) parsed.options.hostname = arg;
29667
31177
  else {
@@ -29701,7 +31211,9 @@ function parseArgs(argv) {
29701
31211
  }
29702
31212
  const imageCount = parsed.options.imageUrls?.length ?? 0;
29703
31213
  if (imageCount > 0) {
29704
- if (parsed.command === "model" && imageCount > 1) {
31214
+ if (parsed.command === "shop" && imageCount > 1) {
31215
+ parsed.error = "genex shop takes one --image: the item's picture.";
31216
+ } else if (parsed.command === "model" && imageCount > 1) {
29705
31217
  parsed.error = "genex model accepts one --image reference. Use character --image for multiple character views.";
29706
31218
  } else if (parsed.command === "character" || parsed.command === "creature") {
29707
31219
  if (["preview", "finalize", "import", "animate", "motions", "adopt"].includes(parsed.options.name ?? "")) {
@@ -29923,12 +31435,14 @@ function applyValueFlag(options, flag, value) {
29923
31435
  options.samples = n;
29924
31436
  break;
29925
31437
  }
31438
+ case "--max-credits":
29926
31439
  case "--max-coins": {
29927
31440
  const n = Number(value);
29928
31441
  if (!Number.isInteger(n) || n < 1) {
29929
- throw new Error(`Invalid --max-coins value: ${value} (whole coin, 1 or more)`);
31442
+ throw new Error(`Invalid ${flag} value: ${value} (whole credits, 1 or more)`);
29930
31443
  }
29931
- options.maxCoins = n;
31444
+ if (flag === "--max-credits") options.maxCredits = n;
31445
+ else options.maxCoins = n;
29932
31446
  break;
29933
31447
  }
29934
31448
  case "--timeout": {
@@ -29949,8 +31463,8 @@ function applyValueFlag(options, flag, value) {
29949
31463
  }
29950
31464
  case "--price": {
29951
31465
  const n = Number(value);
29952
- if (!Number.isInteger(n) || n <= 0) {
29953
- throw new Error(`Invalid --price value: ${value} (whole coin, e.g. 100)`);
31466
+ if (value.trim() === "" || !Number.isInteger(n) || n < 0) {
31467
+ throw new Error(`Invalid --price value: ${value} (whole coin, e.g. 100; 0 is Free)`);
29954
31468
  }
29955
31469
  options.price = n;
29956
31470
  break;
@@ -29958,6 +31472,16 @@ function applyValueFlag(options, flag, value) {
29958
31472
  case "--icon":
29959
31473
  options.icon = value;
29960
31474
  break;
31475
+ // A forgotten value must not swallow the next flag (`--tag --json` would
31476
+ // tag the item "--json" and print no draft) or vanish (`--package ""`).
31477
+ case "--tag":
31478
+ if (!value.trim() || value.startsWith("--")) throw new Error("Missing value for --tag");
31479
+ (options.itemTags ??= []).push(value);
31480
+ break;
31481
+ case "--package":
31482
+ if (!value.trim() || value.startsWith("--")) throw new Error("Missing value for --package");
31483
+ options.packageDir = value;
31484
+ break;
29961
31485
  case "--type": {
29962
31486
  if (value !== "consumable" && value !== "durable" && !MODEL_RIG_TYPES.includes(value)) {
29963
31487
  throw new Error(
@@ -30229,9 +31753,9 @@ async function main() {
30229
31753
  await runBudget(parsed.options);
30230
31754
  break;
30231
31755
  // The in-game model lane: is it live here, what does one call really
30232
- // cost, what should the game declare. `bench` is the only CLI command
30233
- // that spends COIN, so it carries its own --max-coins/--user-approved
30234
- // gate — the credits allowance cannot see that wallet.
31756
+ // cost, what ceiling should the game declare. `bench` spends the
31757
+ // builder's own credits OUTSIDE the asset allowance, so it carries its
31758
+ // own --max-credits/--user-approved gate.
30235
31759
  case "llm":
30236
31760
  await runLlm(parsed.options);
30237
31761
  break;
@@ -30261,7 +31785,10 @@ async function main() {
30261
31785
  await runMakeRemixable(parsed.options);
30262
31786
  break;
30263
31787
  case "shop":
30264
- await runShop(parsed.options);
31788
+ await runShop({ ...parsed.options, picture: parsed.options.imageUrl, modelFile: parsed.options.modelId });
31789
+ break;
31790
+ case "items":
31791
+ await runItems(parsed.options);
30265
31792
  break;
30266
31793
  case "domain":
30267
31794
  await runDomain(parsed.options);