@homie-rocks/studio 0.49.0 → 0.50.1

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/bin/homie-studio.mjs +5 -5
  3. package/functions/FUNCTIONS.md +71 -1
  4. package/guides/app/SKILL.md +130 -1
  5. package/guides/art/SKILL.md +154 -0
  6. package/guides/art/references/RIGHTS.md +20 -0
  7. package/guides/art/scripts/art.mjs +308 -0
  8. package/guides/models/SKILL.md +198 -0
  9. package/guides/models/references/RIGHTS.md +46 -0
  10. package/guides/models/references/models.json +175 -0
  11. package/guides/models/scripts/models.mjs +483 -0
  12. package/guides/music/SKILL.md +197 -0
  13. package/guides/music/references/COMPOSITION.md +59 -0
  14. package/guides/music/references/GAME-AUDIO.md +50 -0
  15. package/guides/music/references/RIGHTS.md +43 -0
  16. package/guides/music/scripts/lib/audio.mjs +258 -0
  17. package/guides/music/scripts/lib/eleven.mjs +260 -0
  18. package/guides/music/scripts/lib/studio.mjs +147 -0
  19. package/guides/music/scripts/music.mjs +644 -0
  20. package/guides/office/SKILL.md +99 -0
  21. package/guides/plan/SKILL.md +126 -0
  22. package/guides/plan/references/CODEX.md +105 -0
  23. package/guides/plan/references/INTERVIEW.md +86 -0
  24. package/guides/playtest/SKILL.md +205 -0
  25. package/guides/playtest/references/METHOD.md +124 -0
  26. package/guides/playtest/references/REVIEWER.md +78 -0
  27. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  28. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  29. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  30. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  31. package/guides/playtest/scripts/playtest.mjs +870 -0
  32. package/guides/servers/SKILL.md +111 -0
  33. package/guides/shop/SKILL.md +4 -0
  34. package/guides/sound/SKILL.md +156 -0
  35. package/guides/sound/references/GAME-SOUND.md +100 -0
  36. package/guides/sound/references/SCORE.md +131 -0
  37. package/guides/sound/references/TRAPS.md +93 -0
  38. package/guides/sound/scripts/lib/measure.mjs +252 -0
  39. package/guides/sound/scripts/lib/score.mjs +415 -0
  40. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  41. package/guides/sound/scripts/lib/synth.mjs +509 -0
  42. package/guides/sound/scripts/sound.mjs +520 -0
  43. package/guides/style/SKILL.md +105 -0
  44. package/lib/app-needs.mjs +63 -0
  45. package/lib/check.mjs +10 -4
  46. package/lib/codex.mjs +95 -18
  47. package/lib/decisions.mjs +9 -7
  48. package/lib/functions-build.mjs +10 -0
  49. package/lib/handoff.mjs +1 -1
  50. package/lib/mcp-tools.mjs +10 -9
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/scaffold.mjs +15 -7
  53. package/lib/studio.mjs +3 -3
  54. package/lib/template-history.json +84 -0
  55. package/lib/worker-config.mjs +1 -1
  56. package/package.json +1 -1
  57. package/shop/SHOP.md +4 -0
  58. package/shop/shop.ts +4 -4
  59. package/tools/TOOLS.md +21 -0
  60. package/tools/tools.ts +1 -1
  61. package/worker/build-id.mjs +1 -1
  62. package/worker/event-resources.mjs +116 -0
  63. package/worker/functions-schema.mjs +15 -0
  64. package/worker/functions.mjs +36 -22
  65. package/worker/index.mjs +23 -3
  66. package/worker/mcp-tools.mjs +14 -3
  67. package/worker/mcp.mjs +45 -8
  68. package/worker/shop-page.mjs +1 -1
  69. package/worker/shop.mjs +6 -2
  70. package/worker/version.mjs +1 -1
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * models.mjs: 3D props made for a game from its locked style, on the creator's OWN fal account, under a hard budget,
4
+ * with a receipt for every call. The free parts (the starter library, optimising, checking, the lineup)
5
+ * are `npx --no-install homie-studio assets …`; this script is only the paid route and its registry.
6
+ *
7
+ * check the LOCAL fal key (a free check), the studio, the registry's endpoints; a fal
8
+ * connector signed in from the app is a separate sign-in this script cannot see
9
+ * registry [--write] every registry endpoint's live price again (free), and what moved or retired
10
+ * budget <game> --cap <usd> what the person agreed to spend on this game's generated models
11
+ * quote <game> [--count <n>] what one prop costs today (concept + mesh), and n of them, against the cap
12
+ * prop <game> <asset> --card "Items/Lantern" --what "<the thing, in plain words>" [--height <m>] [--faces 1500]
13
+ * [--dry-run | --yes]
14
+ * step 1 (no concept yet): ONE concept image in the locked style (the derived style prompt; the golden images as
15
+ * references when there are any), plain background, even light. Then STOP: look at it.
16
+ * step 2 (`prop … --mesh --yes`): Tripo P1 image-to-3D from that concept (textured, face_limit from the tier),
17
+ * then `homie-studio assets add` makes it phone-sized for free and records it with every step and receipt.
18
+ * character <game> <asset> --card "Players/Ranger" --what "<who, in plain words>" [--height <m>] [--like <library item>]
19
+ * [--faces 7000] [--dry-run | --yes]
20
+ * step 1: ONE full-body concept in an A-pose, front view, in the locked style (the golden images, or --like: a
21
+ * library character's thumbnail as the style reference). Then STOP: look at it.
22
+ * step 2 (`character … --mesh --yes`): Meshy 7.1 image-to-3D with a humanoid auto-rig (textured, about US$1.40),
23
+ * then `homie-studio assets add --rigged` makes it phone-sized for free, maps its skeleton to the standard and
24
+ * retargets the library's clips onto it (KayKit's CC0 humanoid set by default; --clips-from another).
25
+ * import <game> <asset> --receipt <job.json> --file <completed.glb> [--kind prop|character] [--card "…"] [--height <m>]
26
+ * [--concept <image> --concept-receipt <job.json>] [--usd <n>] [--dry-run]
27
+ * a job ALREADY RUN through a fal connector (its MCP server, signed in from the app, with no key in this shell),
28
+ * brought in under the same budget, receipts and provenance as one made here: the receipt's request id and cost
29
+ * go to art/receipts.jsonl and the game's budget (refused with no budget, or past the cap), the file is kept in
30
+ * art/<asset>/raw/, then `homie-studio assets add` makes it phone-sized and records every step. No network; the
31
+ * same receipt imported twice is counted once.
32
+ * mood <game> <a|b|c|all> [--yes] a painted mood image per style-board direction (a target, never shipped)
33
+ * receipts <game> what this game's models cost so far, from art/receipts.jsonl
34
+ *
35
+ * Every paid call is priced from fal's own pricing API first (a credit-billed endpoint through the registry's credit
36
+ * table), refused with no budget or past the cap, asked again without --yes, receipted the moment fal accepts it, and
37
+ * resumed (never paid twice) from <out>.request. Never a retry loop: look, change the words, run once more.
38
+ * FAL_QUEUE_URL, FAL_API_URL and FAL_STORAGE_URL point it at a test double. Every command takes --json.
39
+ */
40
+ import { spawnSync } from 'node:child_process';
41
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
42
+ import { dirname, extname, join, relative, resolve } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ import { experienceDir, experienceJson, SLUG, checkBudget, findStudio, readBudget, readJson, setBudget, writeJson } from '../../music/scripts/lib/studio.mjs';
45
+ import { checkKey, falKey, priceOf, run as falRun, unitPrice } from '../../video/scripts/lib/fal.mjs';
46
+
47
+ const HERE = dirname(fileURLToPath(import.meta.url));
48
+ const REGISTRY = join(HERE, '..', 'references', 'models.json');
49
+ const argv = process.argv.slice(2);
50
+ const flags = new Map();
51
+ const pos = [];
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const a = argv[i];
54
+ if (a.startsWith('--')) { const k = a.slice(2); const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) flags.set(k, true); else { flags.set(k, v); i++; } } else pos.push(a);
55
+ }
56
+ const JSON_OUT = flags.has('json');
57
+ const say = (m) => { if (!JSON_OUT) process.stderr.write(`${m}\n`); };
58
+ const rel = (root, f) => relative(root, f).split('\\').join('/');
59
+ const registry = () => JSON.parse(readFileSync(REGISTRY, 'utf8'));
60
+
61
+ function studioRoot() {
62
+ const root = findStudio();
63
+ if (!root) throw new Error('not inside a Homie studio (no studio.json here or above). The studio-setup skill makes one.');
64
+ return root;
65
+ }
66
+ function gameDir(root, game) {
67
+ if (!SLUG.test(String(game ?? ''))) throw new Error('name the game: models.mjs <command> <game> …');
68
+ const d = experienceDir(root, game);
69
+ if (!existsSync(experienceJson(d)) && !existsSync(join(d, 'CODEX.md'))) throw new Error(`no game "${game}" in games/`);
70
+ return d;
71
+ }
72
+ /** The game's money for generated models: art/<game>-models/budget.json (one cap for every model of the game). */
73
+ const budgetDir = (root, game) => join(root, 'art', `${game}-models`);
74
+
75
+ /** The studio's own pinned homie-studio (HOMIE_STUDIO_CLI points at another), run with --json. */
76
+ function studio(root, args) {
77
+ const cli = process.env.HOMIE_STUDIO_CLI || join(root, 'node_modules', '@homie-rocks', 'studio', 'bin', 'homie-studio.mjs');
78
+ if (!existsSync(cli)) throw new Error('the studio has no node_modules yet: run npm install in the studio');
79
+ const r = spawnSync(process.execPath, [cli, ...args, '--json'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000 });
80
+ let out = null;
81
+ try { out = JSON.parse(r.stdout); } catch { out = null; }
82
+ if (r.status !== 0 || !out) throw new Error(`homie-studio ${args.slice(0, 2).join(' ')}: ${out?.why ?? (r.stderr || r.stdout).trim().split('\n').slice(-3).join(' ')}`);
83
+ return out;
84
+ }
85
+
86
+ /** A receipt line: the game's model budget (running total), art/receipts.jsonl, and HOMIE_SPEND_LEDGER when set. */
87
+ function receipt(root, game, line) {
88
+ const at = new Date().toISOString();
89
+ const dir = budgetDir(root, game);
90
+ const b = readBudget(dir);
91
+ if (b) {
92
+ b.spent = +(b.spent + (Number(line.cost) || 0)).toFixed(6);
93
+ b.calls.push({ at, provider: line.provider, model: line.model, cost: line.cost, unit: line.unit, requestId: line.requestId ?? null, artifact: line.artifact ?? null });
94
+ writeJson(join(dir, 'budget.json'), b);
95
+ }
96
+ mkdirSync(join(root, 'art'), { recursive: true });
97
+ appendFileSync(join(root, 'art', 'receipts.jsonl'), `${JSON.stringify({ schema: 'homie.media-receipt/1', at, game, ...line })}\n`);
98
+ const ledger = process.env.HOMIE_SPEND_LEDGER;
99
+ if (ledger) {
100
+ try { appendFileSync(ledger, `${JSON.stringify({ schema: 'homie.brand-spend/1', at, provider: line.provider, model: line.model, jobId: line.requestId ?? null, units: line.units ?? null, unit: line.unitName ?? line.unit, usd: line.cost, credits: null, unpriced: null, brand: process.env.HOMIE_SPEND_BRAND ?? null, attribution: process.env.HOMIE_SPEND_ATTRIBUTION ?? null, artifact: line.artifact ?? null })}\n`); } catch (e) { say(`could not write the ledger: ${e.message}`); }
101
+ }
102
+ }
103
+
104
+ /** A file's real picture type from its first bytes; the extension made to match (fal answers PNG or JPEG). */
105
+ function fixExt(file) {
106
+ const b = readFileSync(file).subarray(0, 4);
107
+ const ext = b[0] === 0xff && b[1] === 0xd8 ? '.jpg' : b[0] === 0x89 && b[1] === 0x50 ? '.png' : b.toString('latin1', 0, 4) === 'RIFF' ? '.webp' : null;
108
+ if (!ext || file.endsWith(ext)) return file;
109
+ const to = file.replace(/\.[a-z0-9]+$/i, ext);
110
+ renameSync(file, to);
111
+ for (const side of ['.json', '.request']) if (existsSync(`${file}${side}`)) renameSync(`${file}${side}`, `${to}${side}`);
112
+ return to;
113
+ }
114
+
115
+ /**
116
+ * One paid call: priced (live), checked against the cap, asked for without --yes, receipted on acceptance, resumed on
117
+ * a rerun. Returns { ok, file, usd } or { ok: false, needs: 'approval', price } or { already }.
118
+ */
119
+ async function paid(root, game, { model, input, out, base, credits = null, addons = null, what, asset }) {
120
+ // Done before (the file and its answer are both there): never paid twice.
121
+ const done = [...new Set(['.png', '.jpg', '.webp', '.glb'].map((e) => out.replace(/\.[a-z0-9]+$/i, e)))].find((f) => existsSync(f) && existsSync(`${f}.json`));
122
+ if (done) return { ok: true, already: true, file: done, usd: readJson(`${done}.json`, {})?.price?.usd ?? 0 };
123
+ const p = await priceOf(model, input, { credits, addons });
124
+ if (flags.has('dry-run')) return { ok: true, dryRun: true, price: p };
125
+ const dir = budgetDir(root, game);
126
+ const resuming = existsSync(`${out}.request`);
127
+ if (!resuming) {
128
+ const b = checkBudget(dir, p.usd, { provider: 'fal', unit: 'usd' });
129
+ if (!flags.has('yes')) return { ok: false, needs: 'approval', price: p, budget: { cap: b.cap, spent: b.spent }, why: `${what} costs about US$${p.usd.toFixed(3)} (${p.basis}) on the person's own fal account; US$${b.spent.toFixed(2)} of US$${b.cap.toFixed(2)} spent so far. Run it with --yes once that is fine.` };
130
+ }
131
+ const r = await falRun(model, input, {
132
+ out, base, uploads: join(dir, 'uploads.json'), price: p, log: say,
133
+ onAccepted: async (q) => { receipt(root, game, { provider: 'fal', model, requestId: q.request_id, cost: p.usd, unit: 'usd', units: p.units, unitName: p.unit, basis: p.basis, artifact: rel(root, out), asset, what }); },
134
+ });
135
+ return { ok: true, file: r.files[0], requestId: r.requestId, usd: p.usd, price: p };
136
+ }
137
+
138
+ /* ---------------------------------------------------------------- commands */
139
+
140
+ /**
141
+ * Two different sign-ins, which this check used to report as one. FAL_KEY is what THIS script pays with. A fal
142
+ * connector (fal's MCP server, signed in from the app) is signed in somewhere this script cannot see: it can be ready
143
+ * while there is no key here, and neither says anything about the other.
144
+ */
145
+ const NO_LOCAL_KEY = 'no local FAL_KEY in this shell, so this script cannot make a paid call itself. That says nothing about a fal connector: one signed in from the app is a separate sign-in this script cannot see or use, and it may well be ready. Two ways on: set FAL_KEY and generate here (prop, character), or generate through the connector and bring the finished job in with `models.mjs import <game> <asset> --receipt <job.json> --file <model.glb>` (the same budget, receipts and provenance). The starter library, optimising and the checks need neither';
146
+
147
+ async function check() {
148
+ const root = findStudio();
149
+ const reg = registry();
150
+ const local = Boolean(falKey());
151
+ const key = local ? await checkKey() : { ok: false, why: NO_LOCAL_KEY };
152
+ const auth = {
153
+ localKey: key.ok ? 'works (checked free)' : local ? `set, but not accepted: ${key.why}` : 'not set',
154
+ connector: 'not checked here: a fal connector signs in from the app, apart from this script. Ask it for its own account status; if it is ready, generate there and `import` the finished job',
155
+ paidRoutes: [...(key.ok ? ['prop, character and mood here (FAL_KEY)'] : []), 'import: a job a fal connector already ran, with its receipt and its file'],
156
+ };
157
+ return { ok: true, command: 'check', studio: root, fal: key.ok ? `the key works (checked free; from ${key.source})` : key.why, auth, registry: { checked: reg.checked, concept: reg.concept.noRefs.endpoint, conceptWithRefs: reg.concept.withRefs.endpoint, mesh: reg.mesh.prop.endpoint, mood: reg.mood.endpoint } };
158
+ }
159
+
160
+ async function registryCheck() {
161
+ const reg = registry();
162
+ const rows = [];
163
+ const entries = [['concept.withRefs', reg.concept.withRefs], ['concept.noRefs', reg.concept.noRefs], ['mesh.prop', reg.mesh.prop], ['mood', reg.mood], ...reg.mesh.fallbacks.map((f, i) => [`mesh.fallbacks.${i}`, f])];
164
+ for (const [at, e] of entries) {
165
+ try {
166
+ const u = await unitPrice(e.endpoint);
167
+ const usd = /^credits?$/i.test(u.unit) && e.credits ? +(u.unitPrice * (e.credits.base + (e.credits.texture && e.input?.texture !== false ? e.credits.texture : 0))).toFixed(4) : u.unitPrice;
168
+ const moved = Math.abs(usd - Number(e.price.usd)) > 1e-6;
169
+ rows.push({ at, endpoint: e.endpoint, unit: u.unit, unitPrice: u.unitPrice, usd, recorded: e.price.usd, moved });
170
+ if (flags.has('write')) { e.price.usd = usd; e.price.read = new Date().toISOString().slice(0, 10); }
171
+ } catch (error) { rows.push({ at, endpoint: e.endpoint, error: /404/.test(error.message) ? 'retired or renamed (404): find its successor on fal.ai/explore/3d and update the registry' : error.message }); }
172
+ }
173
+ if (flags.has('write')) { reg.checked = new Date().toISOString().slice(0, 10); writeFileSync(REGISTRY, `${JSON.stringify(reg, null, 2)}\n`); }
174
+ return { ok: rows.every((r) => !r.error), command: 'registry', checked: reg.checked, rows };
175
+ }
176
+
177
+ function budget(root) {
178
+ const game = pos[1];
179
+ gameDir(root, game);
180
+ const cap = Number(flags.get('cap'));
181
+ if (!(cap >= 0 && cap <= 1000)) throw new Error('--cap <US dollars the person agreed to for this game\'s models>');
182
+ const b = setBudget(budgetDir(root, game), { provider: 'fal', unit: 'usd', cap, note: 'generated models and mood images for this game' });
183
+ // The decisions file carries the art budget too (the automatic path uses only free routes without one).
184
+ try { studio(root, ['style', 'init', game, '--budget', String(cap)]); } catch (error) { say(`decisions not updated: ${error.message}`); }
185
+ return { ok: true, command: 'budget', game, cap: b.cap, spent: b.spent };
186
+ }
187
+
188
+ /** The concept's words: the thing, then the derived style prompt (never edited by hand), then how to frame it. */
189
+ function conceptInput(root, game, what) {
190
+ const reg = registry();
191
+ const style = studio(root, ['style', 'prompt', game]);
192
+ const golden = (style.golden ?? []).map((g) => relative(root, join(experienceDir(root, game), g.path))).filter((p) => existsSync(join(root, p))).slice(0, 4);
193
+ const prompt = `${String(what).trim().replace(/\.$/, '')}. A single game prop, the whole object in frame, centred, seen three-quarters from slightly above. ${style.prompt.text}.`;
194
+ if (golden.length) {
195
+ const c = reg.concept.withRefs;
196
+ return { model: c.endpoint, input: { ...c.input, prompt: `${prompt} Match the style of the reference images exactly; do not copy their content.`, [c.refs]: golden.map((g) => `@file:${g}`) }, refs: golden, rev: style.prompt.rev };
197
+ }
198
+ const c = reg.concept.noRefs;
199
+ return { model: c.endpoint, input: { ...c.input, prompt }, refs: [], rev: style.prompt.rev };
200
+ }
201
+
202
+ async function prop(root) {
203
+ const [, game, asset] = pos;
204
+ gameDir(root, game);
205
+ if (!SLUG.test(String(asset ?? ''))) throw new Error('name the asset: models.mjs prop <game> <asset id> --card "Items/<Name>" --what "<the thing>"');
206
+ const reg = registry();
207
+ const dir = join(root, 'art', asset);
208
+ mkdirSync(join(dir, 'raw'), { recursive: true });
209
+ const conceptFile = ['concept.png', 'concept.jpg', 'concept.webp'].map((f) => join(dir, f)).find((f) => existsSync(f) && existsSync(`${f}.json`));
210
+ const card = String(flags.get('card') ?? `Items/${asset}`);
211
+ const height = flags.has('height') ? Number(flags.get('height')) : null;
212
+ // Step 1: the concept, then stop and look.
213
+ if (!conceptFile || flags.has('concept-again')) {
214
+ const what = flags.get('what');
215
+ if (!what) throw new Error('--what "<the thing, in plain words: shape, material, size>" (the style comes from the game\'s locked decisions)');
216
+ const c = conceptInput(root, game, what);
217
+ if (flags.has('concept-again') && conceptFile) {
218
+ const was = `concept-was-${Date.now()}`;
219
+ for (const f of [conceptFile, `${conceptFile}.json`, `${conceptFile}.request`]) if (existsSync(f)) renameSync(f, f.replace(/concept(?=\.[a-z]+)/, was));
220
+ }
221
+ const meshPrice = await priceOf(reg.mesh.prop.endpoint, { ...reg.mesh.prop.input, image_url: 'x' }, { credits: reg.mesh.prop.credits }).catch(() => null);
222
+ const r = await paid(root, game, { model: c.model, input: c.input, out: join(dir, 'concept.png'), base: root, what: `the concept image for ${asset}`, asset });
223
+ if (!r.ok || r.dryRun) return { ...r, command: 'prop', step: 'concept', game, asset, then: meshPrice ? { what: 'the mesh (Tripo P1, textured)', usd: meshPrice.usd } : null, total: r.price && meshPrice ? +(r.price.usd + meshPrice.usd).toFixed(3) : null };
224
+ const file = fixExt(r.file);
225
+ writeJson(join(dir, 'concept.meta.json'), { what: String(what), card, height, model: c.model, refs: c.refs, promptRev: c.rev, prompt: c.input.prompt });
226
+ return { ok: true, command: 'prop', step: 'concept', game, asset, concept: rel(root, file), usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, next: `look at ${rel(root, file)} first (does it read as the thing, in the game's style, whole, on a plain background?). Then: models.mjs prop ${game} ${asset} --mesh --yes (about US$${meshPrice?.usd ?? '0.50'}); or change --what and --concept-again (never a loop)` };
227
+ }
228
+ if (!flags.has('mesh')) return { ok: true, command: 'prop', step: 'look', game, asset, concept: rel(root, conceptFile), next: `the concept exists: look at it, then models.mjs prop ${game} ${asset} --mesh --yes` };
229
+ // Step 2: the mesh from the concept, then optimised and recorded for free.
230
+ const meta = readJson(join(dir, 'concept.meta.json'), {});
231
+ const m = reg.mesh.prop;
232
+ const tierFaces = Number(flags.get('faces') ?? 1500);
233
+ const input = { ...m.input, image_url: `@file:${rel(root, conceptFile)}`, [m.faceLimit]: tierFaces };
234
+ const meshOut = join(dir, 'raw', 'mesh.glb');
235
+ const r = await paid(root, game, { model: m.endpoint, input, out: meshOut, base: root, credits: m.credits, what: `the 3D model for ${asset}`, asset });
236
+ if (!r.ok || r.dryRun) return { ...r, command: 'prop', step: 'mesh', game, asset };
237
+ const concept = readJson(`${conceptFile}.json`, {});
238
+ const mesh = readJson(`${r.file}.json`, {});
239
+ const steps = [
240
+ { what: 'concept', provider: 'fal', endpoint: concept.model ?? meta.model, usd: concept.price?.usd ?? null, receipt: rel(root, `${conceptFile}.json`), refs: meta.refs ?? [], promptRev: meta.promptRev ?? null, requestId: concept.requestId ?? null, at: concept.at ?? null },
241
+ { what: 'mesh', provider: 'fal', endpoint: m.endpoint, input: { [m.faceLimit]: tierFaces, texture: true }, usd: mesh.price?.usd ?? r.usd, receipt: rel(root, `${r.file}.json`), requestId: mesh.requestId ?? r.requestId ?? null, at: mesh.at ?? null },
242
+ ];
243
+ const stepsFile = join(dir, 'steps.json');
244
+ writeJson(stepsFile, steps);
245
+ const added = studio(root, ['assets', 'add', game, '--file', rel(root, r.file), '--route', 'generated', '--license', 'generated', '--as', asset, '--kind', 'prop', '--card', meta.card ?? card, '--steps', rel(root, stepsFile), '--concept', rel(root, conceptFile), '--slug', asset, ...((meta.height ?? height) ? ['--height', String(meta.height ?? height)] : [])]);
246
+ return { ok: true, command: 'prop', step: 'mesh', game, asset, model: added.file, before: added.before, after: added.after, usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, receipts: steps.map((s) => s.receipt), next: `homie-studio assets lineup ${game} (true scale, silhouettes, palette drift); look at it` };
247
+ }
248
+
249
+ /** A character's concept words: who, the derived style prompt without its prop framing, then an A-pose, front view. */
250
+ function characterConcept(root, game, what, like) {
251
+ const reg = registry();
252
+ const style = studio(root, ['style', 'prompt', game]);
253
+ const golden = (style.golden ?? []).map((g) => relative(root, join(experienceDir(root, game), g.path))).filter((p) => existsSync(join(root, p))).slice(0, 4);
254
+ const refs = [...golden, ...(like ? [like] : [])].slice(0, 4);
255
+ const look = String(style.prompt.text).replace(/,?\s*3\/4 view, centred, whole object in frame[^]*$/, '');
256
+ const prompt = `${String(what).trim().replace(/\.$/, '')}. One game character, full body from head to feet, standing in an A-pose (arms straight and angled down about 45 degrees away from the body, hands open, legs slightly apart), front view facing the camera, centred. ${look}, lit evenly and neutrally for modelling, plain light grey background, no ground shadow, no text, no logo.`;
257
+ if (refs.length) {
258
+ const c = reg.concept.withRefs;
259
+ return { model: c.endpoint, input: { ...c.input, image_size: { width: 1536, height: 2560 }, prompt: `${prompt} Match the style of the reference images exactly (shapes, proportions, colours, how it is shaded); do not copy their content.`, [c.refs]: refs.map((g) => `@file:${g}`) }, refs, rev: style.prompt.rev };
260
+ }
261
+ const c = reg.concept.noRefs;
262
+ return { model: c.endpoint, input: { ...c.input, image_size: { width: 1536, height: 2560 }, prompt }, refs: [], rev: style.prompt.rev };
263
+ }
264
+
265
+ /** A library item's thumbnail into the job's folder, as a style reference (CC0, the library's own render). */
266
+ async function likeRef(root, dir, item) {
267
+ if (!item) return null;
268
+ // The studio's own toolkit and its sharp (both installed in the studio), never this plugin's.
269
+ const { createRequire } = await import('node:module');
270
+ const { pathToFileURL } = await import('node:url');
271
+ const need = createRequire(join(root, 'package.json'));
272
+ const { libraryBase, loadIndex, itemThumb } = await import(pathToFileURL(join(root, 'node_modules', '@homie-rocks', 'studio', 'lib', 'library.mjs')).href);
273
+ const { index, lib } = await loadIndex({ lib: libraryBase() });
274
+ const it = index.items.find((x) => x.id === item);
275
+ if (!it) throw new Error(`no library item "${item}" (homie-studio assets find "<words>" --kind character)`);
276
+ const bytes = await itemThumb(lib, it);
277
+ if (!bytes) throw new Error(`${item} has no thumbnail`);
278
+ const sharp = (await import(pathToFileURL(need.resolve('sharp')).href)).default;
279
+ const file = join(dir, 'like.png');
280
+ await sharp(Buffer.from(bytes)).resize(768, 768, { fit: 'contain', background: '#d9d9d9' }).flatten({ background: '#d9d9d9' }).png().toFile(file);
281
+ return rel(root, file);
282
+ }
283
+
284
+ async function character(root) {
285
+ const [, game, asset] = pos;
286
+ gameDir(root, game);
287
+ if (!SLUG.test(String(asset ?? ''))) throw new Error('name the asset: models.mjs character <game> <asset id> --card "Players/<Name>" --what "<who>"');
288
+ const reg = registry();
289
+ const dir = join(root, 'art', asset);
290
+ mkdirSync(join(dir, 'raw'), { recursive: true });
291
+ const conceptFile = ['concept.png', 'concept.jpg', 'concept.webp'].map((f) => join(dir, f)).find((f) => existsSync(f) && existsSync(`${f}.json`));
292
+ const card = String(flags.get('card') ?? `Players/${asset}`);
293
+ const height = flags.has('height') ? Number(flags.get('height')) : null;
294
+ const m = reg.character.mesh;
295
+ const meshInput = (concept) => ({ ...m.input, image_url: concept, [m.polycount]: Number(flags.get('faces') ?? m.input.target_polycount), ...(height ? { [m.height]: height } : {}) });
296
+ if (!conceptFile || flags.has('concept-again')) {
297
+ const what = flags.get('what');
298
+ if (!what) throw new Error('--what "<who, in plain words: build, clothes, colours, what they carry>" (the style comes from the game\'s locked decisions)');
299
+ const like = flags.has('dry-run') ? (flags.get('like') ? 'like.png' : null) : await likeRef(root, dir, flags.get('like') ?? null);
300
+ const c = characterConcept(root, game, what, like);
301
+ if (flags.has('concept-again') && conceptFile) {
302
+ const was = `concept-was-${Date.now()}`;
303
+ for (const f of [conceptFile, `${conceptFile}.json`, `${conceptFile}.request`]) if (existsSync(f)) renameSync(f, f.replace(/concept(?=\.[a-z]+)/, was));
304
+ }
305
+ const meshPrice = await priceOf(m.endpoint, meshInput('x'), { addons: m.addons }).catch(() => null);
306
+ const r = await paid(root, game, { model: c.model, input: c.input, out: join(dir, 'concept.png'), base: root, what: `the character concept for ${asset}`, asset });
307
+ if (!r.ok || r.dryRun) return { ...r, command: 'character', step: 'concept', game, asset, then: meshPrice ? { what: 'the rigged, textured model (Meshy 7.1)', usd: meshPrice.usd, basis: meshPrice.basis } : null, total: r.price && meshPrice ? +(r.price.usd + meshPrice.usd).toFixed(3) : null };
308
+ const file = fixExt(r.file);
309
+ writeJson(join(dir, 'concept.meta.json'), { what: String(what), card, height, model: c.model, refs: c.refs, promptRev: c.rev, prompt: c.input.prompt, like: flags.get('like') ?? null });
310
+ return { ok: true, command: 'character', step: 'concept', game, asset, concept: rel(root, file), usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, next: `look at ${rel(root, file)} first (one character, whole, in an A-pose, in the game's style, on a plain background?). Then: models.mjs character ${game} ${asset} --mesh --yes (about US$${meshPrice?.usd ?? '1.40'}); or change --what and --concept-again (never a loop)` };
311
+ }
312
+ if (!flags.has('mesh')) return { ok: true, command: 'character', step: 'look', game, asset, concept: rel(root, conceptFile), next: `the concept exists: look at it, then models.mjs character ${game} ${asset} --mesh --yes` };
313
+ const meta = readJson(join(dir, 'concept.meta.json'), {});
314
+ const input = meshInput(`@file:${rel(root, conceptFile)}`);
315
+ if (meta.height && !height) input[m.height] = meta.height;
316
+ const meshOut = join(dir, 'raw', 'rigged.glb');
317
+ const r = await paid(root, game, { model: m.endpoint, input, out: meshOut, base: root, credits: null, addons: m.addons, what: `the rigged 3D character for ${asset}`, asset });
318
+ if (!r.ok || r.dryRun) return { ...r, command: 'character', step: 'mesh', game, asset };
319
+ const concept = readJson(`${conceptFile}.json`, {});
320
+ const mesh = readJson(`${r.file}.json`, {});
321
+ const steps = [
322
+ { what: 'concept', provider: 'fal', endpoint: concept.model ?? meta.model, usd: concept.price?.usd ?? null, receipt: rel(root, `${conceptFile}.json`), refs: meta.refs ?? [], promptRev: meta.promptRev ?? null, requestId: concept.requestId ?? null, at: concept.at ?? null },
323
+ { what: 'mesh and rig', provider: 'fal', endpoint: m.endpoint, input: { [m.polycount]: input[m.polycount], pose_mode: input.pose_mode, enable_rigging: true, should_texture: true }, usd: mesh.price?.usd ?? r.usd, receipt: rel(root, `${r.file}.json`), requestId: mesh.requestId ?? r.requestId ?? null, at: mesh.at ?? null },
324
+ ];
325
+ const stepsFile = join(dir, 'steps.json');
326
+ writeJson(stepsFile, steps);
327
+ const added = studio(root, ['assets', 'add', game, '--file', rel(root, r.file), '--route', 'generated', '--license', 'generated', '--as', asset, '--kind', 'character', '--rigged', '--card', meta.card ?? card, '--steps', rel(root, stepsFile), '--concept', rel(root, conceptFile), '--slug', asset, ...(flags.get('clips-from') ? ['--clips-from', String(flags.get('clips-from'))] : []), ...((meta.height ?? height) ? ['--height', String(meta.height ?? height)] : [])]);
328
+ return { ok: true, command: 'character', step: 'mesh', game, asset, model: added.model ?? added.file, anims: added.anims ?? null, skeleton: added.skeleton ?? null, verbs: added.verbs ?? [], before: added.before, after: added.after, usd: r.usd, spent: readBudget(budgetDir(root, game))?.spent ?? null, receipts: steps.map((s) => s.receipt), next: `homie-studio anim preview ${game} --asset ${asset} (its clips, looping) and homie-studio assets lineup ${game}; look at both` };
329
+ }
330
+
331
+ /**
332
+ * A connector's job receipt, read tolerantly (what was saved when the connector accepted the job): its request id, its
333
+ * endpoint, what it cost or was estimated to cost, and when. A receipt that says the job is not finished is refused.
334
+ */
335
+ function jobReceipt(file, { usd = null } = {}) {
336
+ if (!file) return null;
337
+ const abs = resolve(String(file));
338
+ if (!existsSync(abs)) throw new Error(`no receipt at ${file}`);
339
+ let j;
340
+ try { j = JSON.parse(readFileSync(abs, 'utf8')); } catch { throw new Error(`${file} is not JSON (a connector job receipt: its request id, endpoint and cost)`); }
341
+ const requestId = [j.requestId, j.request_id, j.id, j.job?.request_id, j.job?.id].find((x) => typeof x === 'string' && x.trim());
342
+ const endpoint = [j.endpoint, j.model, j.app, j.endpoint_id, j.job?.endpoint].find((x) => typeof x === 'string' && x.trim());
343
+ if (!requestId) throw new Error(`${file} names no request id (requestId): without it a job cannot be told from the one before, so it is not recorded`);
344
+ if (!endpoint) throw new Error(`${file} names no endpoint (endpoint or model): the provenance of the file would be a guess`);
345
+ const status = String(j.status ?? j.state ?? '').toLowerCase();
346
+ if (status && !/^(completed?|succe(ss|eded)|ok|done|finished)$/.test(status)) throw new Error(`${file} says the job is "${status}", not finished: import it when the connector has the result`);
347
+ const pick = [usd, j.usd, j.cost, j.price?.usd, j.estimate?.usd, j.estimatedCost, j.estimated_cost, j.price].map((x) => (x === null || x === undefined || x === '' || typeof x === 'object' ? NaN : Number(x))).find((n) => Number.isFinite(n) && n >= 0);
348
+ // No cost on the receipt: the registry's recorded price for that endpoint, said as such. Never zero by default.
349
+ let price = Number.isFinite(pick) ? { usd: pick, basis: usd !== null && Number(usd) === pick ? 'the price given with --usd' : 'the connector receipt\'s own cost' } : null;
350
+ if (!price) {
351
+ const reg = registry();
352
+ const known = [reg.concept.withRefs, reg.concept.noRefs, reg.mesh.prop, reg.mood, reg.character?.mesh, ...(reg.mesh.fallbacks ?? [])].find((e) => e?.endpoint === endpoint);
353
+ if (known && Number.isFinite(Number(known.price?.usd))) price = { usd: Number(known.price.usd), basis: `the registry's recorded price for ${endpoint} (read ${known.price.read ?? reg.checked}); the receipt names none` };
354
+ }
355
+ if (!price) throw new Error(`${file} names no cost and ${endpoint} is not in the registry: say what it cost with --usd <US dollars> (an unknown cost is never recorded as nothing)`);
356
+ return { file: abs, requestId: requestId.trim(), endpoint: endpoint.trim(), price, at: typeof (j.at ?? j.createdAt ?? j.created_at) === 'string' ? (j.at ?? j.createdAt ?? j.created_at) : null };
357
+ }
358
+
359
+ /** Request ids this studio has a receipt line for already: importing the same connector job twice counts it once. */
360
+ function receipted(root) {
361
+ try { return new Set(readFileSync(join(root, 'art', 'receipts.jsonl'), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l).requestId ?? null; } catch { return null; } }).filter(Boolean)); } catch { return new Set(); }
362
+ }
363
+
364
+ /**
365
+ * `import`: a job a fal connector already ran, into the game as if this script had run it. The money was spent on the
366
+ * connector's sign-in, so nothing here can stop it; what this does is keep the books and the provenance true: the cap
367
+ * the person agreed to is still checked (a job past it is refused until they raise it), every request id gets one
368
+ * receipt line, the raw file is kept beside its job, and the asset is recorded with each step's endpoint and receipt.
369
+ */
370
+ async function importJob(root) {
371
+ const [, game, asset] = pos;
372
+ gameDir(root, game);
373
+ if (!SLUG.test(String(asset ?? ''))) throw new Error('name the asset: models.mjs import <game> <asset id> --receipt <job.json> --file <model.glb>');
374
+ if (!flags.get('file') || !flags.get('receipt')) throw new Error('models.mjs import <game> <asset> --receipt <the connector\'s job receipt, JSON> --file <the completed .glb> [--kind prop|character] [--concept <image> --concept-receipt <job.json>]');
375
+ const src = resolve(String(flags.get('file')));
376
+ if (!existsSync(src)) throw new Error(`no completed file at ${flags.get('file')}`);
377
+ if (!/\.glb$/i.test(src)) throw new Error('the completed file must be a .glb (ask the connector for the GLB output)');
378
+ const kind = flags.get('kind') === 'character' ? 'character' : 'prop';
379
+ const mesh = jobReceipt(flags.get('receipt'), { usd: flags.has('usd') ? Number(flags.get('usd')) : null });
380
+ const conceptSrc = flags.get('concept') ? resolve(String(flags.get('concept'))) : null;
381
+ if (conceptSrc && !existsSync(conceptSrc)) throw new Error(`no concept image at ${flags.get('concept')}`);
382
+ const concept = conceptSrc && flags.get('concept-receipt') ? jobReceipt(flags.get('concept-receipt')) : null;
383
+ const seen = receipted(root);
384
+ const jobs = [...(concept ? [{ ...concept, what: `the concept image for ${asset}` }] : []), { ...mesh, what: kind === 'character' ? `the rigged 3D character for ${asset}` : `the 3D model for ${asset}` }];
385
+ const fresh = jobs.filter((j) => !seen.has(j.requestId));
386
+ const owed = +fresh.reduce((n, j) => n + j.price.usd, 0).toFixed(6);
387
+ const dir = join(root, 'art', asset);
388
+ if (flags.has('dry-run')) return { ok: true, command: 'import', dryRun: true, game, asset, kind, jobs: jobs.map((j) => ({ what: j.what, endpoint: j.endpoint, requestId: j.requestId, usd: j.price.usd, basis: j.price.basis, counted: seen.has(j.requestId) ? 'already receipted: not counted again' : 'would be counted' })), usd: owed, budget: readBudget(budgetDir(root, game)) ? { cap: readBudget(budgetDir(root, game)).cap, spent: readBudget(budgetDir(root, game)).spent } : null };
389
+ // The same cap as a call made here. It is checked before anything is written, so a refused import leaves no trace.
390
+ if (fresh.length) {
391
+ try { checkBudget(budgetDir(root, game), owed, { provider: 'fal', unit: 'usd' }); } catch (error) { throw new Error(`${error.message} (this job already ran on the fal connector; importing it only records it, and it is recorded once the budget allows it)`); }
392
+ }
393
+ mkdirSync(join(dir, 'raw'), { recursive: true });
394
+ const keep = (from, to) => { if (resolve(from) !== resolve(to)) copyFileSync(from, to); return to; };
395
+ const rawFile = keep(src, join(dir, 'raw', kind === 'character' ? 'rigged.glb' : 'mesh.glb'));
396
+ const conceptFile = conceptSrc ? keep(conceptSrc, join(dir, `concept${extname(conceptSrc).toLowerCase() || '.png'}`)) : null;
397
+ // Beside each file, what `paid` leaves beside one it made: a later `prop --mesh` then finds the job done and never pays for it again.
398
+ const answer = (file, j) => writeJson(`${file}.json`, { model: j.endpoint, requestId: j.requestId, price: { usd: j.price.usd, basis: j.price.basis }, at: j.at ?? new Date().toISOString(), via: 'connector', receipt: rel(root, j.file).startsWith('..') ? null : rel(root, j.file) });
399
+ answer(rawFile, mesh);
400
+ if (conceptFile && concept) answer(conceptFile, concept);
401
+ for (const j of fresh) receipt(root, game, { provider: 'fal', via: 'connector', model: j.endpoint, requestId: j.requestId, cost: j.price.usd, unit: 'usd', basis: j.price.basis, artifact: rel(root, j === mesh || j.requestId === mesh.requestId ? rawFile : conceptFile), asset, what: j.what });
402
+ const steps = [
403
+ ...(concept ? [{ what: 'concept', provider: 'fal', via: 'connector', endpoint: concept.endpoint, usd: concept.price.usd, receipt: rel(root, `${conceptFile}.json`), requestId: concept.requestId, at: concept.at }] : []),
404
+ { what: kind === 'character' ? 'mesh and rig' : 'mesh', provider: 'fal', via: 'connector', endpoint: mesh.endpoint, usd: mesh.price.usd, receipt: rel(root, `${rawFile}.json`), requestId: mesh.requestId, at: mesh.at },
405
+ ];
406
+ const stepsFile = join(dir, 'steps.json');
407
+ writeJson(stepsFile, steps);
408
+ const card = String(flags.get('card') ?? `${kind === 'character' ? 'Players' : 'Items'}/${asset}`);
409
+ const added = studio(root, ['assets', 'add', game, '--file', rel(root, rawFile), '--route', 'generated', '--license', 'generated', '--as', asset, '--kind', kind, ...(kind === 'character' ? ['--rigged'] : []), '--card', card, '--steps', rel(root, stepsFile), ...(conceptFile ? ['--concept', rel(root, conceptFile)] : []), '--slug', asset, ...(flags.get('clips-from') ? ['--clips-from', String(flags.get('clips-from'))] : []), ...(flags.has('height') ? ['--height', String(Number(flags.get('height')))] : [])]);
410
+ const b = readBudget(budgetDir(root, game));
411
+ return { ok: true, command: 'import', game, asset, kind, via: 'connector', model: added.model ?? added.file, before: added.before, after: added.after, usd: owed, counted: fresh.map((j) => j.requestId), alreadyCounted: jobs.filter((j) => seen.has(j.requestId)).map((j) => j.requestId), spent: b?.spent ?? null, cap: b?.cap ?? null, receipts: steps.map((s) => s.receipt), next: `homie-studio assets lineup ${game} (true scale, silhouettes, palette drift); look at it` };
412
+ }
413
+
414
+ async function quote(root) {
415
+ const game = pos[1];
416
+ gameDir(root, game);
417
+ const reg = registry();
418
+ const n = Math.max(1, Number(flags.get('count') ?? 1));
419
+ const c = await priceOf(reg.concept.noRefs.endpoint, { ...reg.concept.noRefs.input, prompt: 'x' });
420
+ const m = await priceOf(reg.mesh.prop.endpoint, { ...reg.mesh.prop.input, image_url: 'x' }, { credits: reg.mesh.prop.credits });
421
+ const one = +(c.usd + m.usd).toFixed(3);
422
+ const b = readBudget(budgetDir(root, game));
423
+ return { ok: true, command: 'quote', game, concept: c, mesh: m, perProp: one, count: n, total: +(one * n).toFixed(3), budget: b ? { cap: b.cap, spent: b.spent, left: +(b.cap - b.spent).toFixed(3) } : null, note: 'a redo of one step costs that step again; a budget of about US$1.10 a prop allows one redo' };
424
+ }
425
+
426
+ async function mood(root) {
427
+ const [, game, which] = pos;
428
+ gameDir(root, game);
429
+ const reg = registry();
430
+ const doc = readJson(join(experienceDir(root, game), 'codex', 'decisions.json'), null);
431
+ const dirs = (doc?.board?.directions ?? []).filter((d) => which === 'all' || d.id === which);
432
+ if (!dirs.length) throw new Error(`no style-board direction "${which}" (homie-studio style board ${game} draws the board first)`);
433
+ const out = [];
434
+ for (const d of dirs) {
435
+ const p = d.values['style.palette'] ?? {};
436
+ const prompt = `A painted mood image (key art) for a game: ${d.label}. ${String(doc.prompt ?? '').slice(0, 240)}. Colours ${[p.bg, p.accent, p.accent2, p.gold, p.good].filter(Boolean).join(' ')}, ${d.values['style.light']?.time ?? ''} light, seen from a ${d.values['style.camera']?.angle ?? 'three-quarter'} camera. Concept painting, no text, no logo, no real people or brands.`;
437
+ const file = join(root, 'art', `${game}-board`, `${d.id}-mood.png`);
438
+ const r = await paid(root, game, { model: reg.mood.endpoint, input: { ...reg.mood.input, prompt }, out: file, base: root, what: `a mood image for direction ${d.id.toUpperCase()}`, asset: `board-${d.id}` });
439
+ if (!r.ok || r.dryRun) { out.push({ direction: d.id, ...r }); if (!r.ok) break; continue; }
440
+ const fixed = fixExt(r.file);
441
+ studio(root, ['style', 'mood', game, d.id, '--image', fixed, '--usd', String(r.usd), '--receipt', rel(root, `${fixed}.json`), '--model', reg.mood.endpoint]);
442
+ out.push({ direction: d.id, ok: true, file: rel(root, fixed), usd: r.usd });
443
+ }
444
+ const stop = out.find((x) => x.ok === false);
445
+ return { ok: !stop, command: 'mood', game, images: out, ...(stop ? { needs: stop.needs, why: stop.why } : {}), note: 'a mood image is a target, labelled so, never what the game draws' };
446
+ }
447
+
448
+ function receiptsOf(root) {
449
+ const game = pos[1];
450
+ const lines = existsSync(join(root, 'art', 'receipts.jsonl')) ? readFileSync(join(root, 'art', 'receipts.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter((x) => x && (!game || x.game === game)) : [];
451
+ const b = game ? readBudget(budgetDir(root, game)) : null;
452
+ return { ok: true, command: 'receipts', game: game ?? null, count: lines.length, usd: +lines.reduce((n, l) => n + (Number(l.cost) || 0), 0).toFixed(3), budget: b ? { cap: b.cap, spent: b.spent } : null, receipts: lines.map((l) => ({ at: l.at, what: l.what ?? l.artifact, model: l.model, usd: l.cost, requestId: l.requestId })) };
453
+ }
454
+
455
+ async function main() {
456
+ const cmd = pos[0];
457
+ if (!cmd || cmd === 'help' || flags.has('help')) return { ok: true, command: 'help', usage: readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0].replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '').trim() };
458
+ if (cmd === 'check') return check();
459
+ if (cmd === 'registry') return registryCheck();
460
+ const root = studioRoot();
461
+ if (cmd === 'budget') return budget(root);
462
+ if (cmd === 'quote') return quote(root);
463
+ if (cmd === 'prop') return prop(root);
464
+ if (cmd === 'character') return character(root);
465
+ if (cmd === 'import') return importJob(root);
466
+ if (cmd === 'mood') return mood(root);
467
+ if (cmd === 'receipts') return receiptsOf(root);
468
+ return { ok: false, command: cmd, why: `unknown command "${cmd}" (models.mjs help)` };
469
+ }
470
+
471
+ const print = (o) => {
472
+ if (JSON_OUT) { process.stdout.write(`${JSON.stringify(o, null, 2)}\n`); return; }
473
+ if (o.ok === false && o.why) { process.stdout.write(`models: ${o.why}\n`); return; }
474
+ for (const [k, v] of Object.entries(o)) if (k !== 'ok' && k !== 'command' && v !== undefined) process.stdout.write(`${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}\n`);
475
+ };
476
+
477
+ try {
478
+ const r = await main();
479
+ if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
480
+ } catch (error) {
481
+ print({ ok: false, why: error instanceof Error ? error.message : String(error) });
482
+ process.exitCode = 1;
483
+ }